├── .gitignore ├── Assets.xcassets ├── AppIcon.appiconset │ ├── AppIcon 1.png │ └── Contents.json └── Contents.json ├── Assets └── README │ ├── app_icon.png │ ├── game.png │ ├── high_scores.png │ ├── mac_os.png │ └── settings.png ├── Colors.xcassets ├── Contents.json ├── background.colorset │ └── Contents.json ├── darkBlue4.colorset │ └── Contents.json ├── darkRed5.colorset │ └── Contents.json ├── lightBlue6.colorset │ └── Contents.json ├── overlay.colorset │ └── Contents.json ├── selectedOverlay.colorset │ └── Contents.json └── shadow.colorset │ └── Contents.json ├── Info.plist ├── LICENSE ├── README.md ├── TCAminesweeper.entitlements ├── TCAminesweeper.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved └── xcshareddata │ └── xcschemes │ ├── GameCore.xcscheme │ ├── LiveHighScoreService.xcscheme │ ├── MainAppCore.xcscheme │ └── TCAminesweeper.xcscheme ├── TCAminesweeper ├── Common │ ├── Live │ │ ├── LiveAppEnvirontment.swift │ │ ├── LiveGameEnvironment.swift │ │ ├── LiveHighScoreEnvironment.swift │ │ ├── LiveMainAppEnvironment.swift │ │ ├── LiveMinefieldEnvironment.swift │ │ ├── LiveNewGameEnvironment.swift │ │ └── LiveSettingsEnvironment.swift │ └── Models │ │ ├── Difficulty.swift │ │ ├── Grid.swift │ │ ├── GridInfo.swift │ │ ├── MinefieldAttributes.swift │ │ ├── Shuffler.swift │ │ ├── UserHighScore.swift │ │ └── UserSettings.swift ├── Constants │ ├── Colors.swift │ └── Fonts.swift ├── Core │ ├── AppCore.swift │ ├── GameCore.swift │ ├── HeaderCore.swift │ ├── HighScoreCore.swift │ ├── MainAppCore.swift │ ├── MinefieldCore.swift │ ├── NewGameCore.swift │ ├── SettingsCore.swift │ └── TileCore.swift ├── Extensions │ ├── Difficulty+MinefieldAttributes.swift │ ├── Grid+String.swift │ └── MinefieldAttributes+Normalize.swift ├── Services │ ├── HighScoreService.swift │ ├── Live │ │ ├── LiveHighScoreService.swift │ │ └── LiveSettingsService.swift │ └── SettingsService.swift ├── SwiftUIViews │ ├── TextAlert.swift │ └── TileViews.swift ├── TCAminesweeperApp.swift └── Views │ ├── AppView.swift │ ├── GameView.swift │ ├── HighScoreView.swift │ ├── MainAppScene.swift │ ├── MinefieldView.swift │ ├── NewGameView.swift │ ├── SettingsView.swift │ └── TileView.swift └── Tests ├── AppCoreTests.swift ├── GameCoreTests.swift ├── GameViewTests.swift ├── Live ├── GridTests.swift ├── LiveHighScoreServiceTests.swift ├── LiveMinefieldEnvironmentTests.swift ├── LiveNewGameEnvironmentTests.swift ├── LiveSettingsServiceTests.swift ├── MinefieldAttributesTests.swift └── __Snapshots__ │ ├── GridTests │ ├── test_indexesBeside_0.1.txt │ ├── test_indexesBeside_10.1.txt │ ├── test_indexesBeside_12.1.txt │ └── test_indexesBeside_24.1.txt │ ├── LiveMinefieldEnvironmentTests │ ├── test_prepareStateForLoss.1.txt │ ├── test_prepareStateForWin.1.txt │ ├── test_revealTilesBesideTile.1.txt │ ├── test_tileTappedHandler_tileEmpty.1.txt │ ├── test_tileTappedHandler_tileMine.1.txt │ ├── test_tileTappedHandler_tileNumber.1.txt │ └── test_tileTappedHandler_tileNumber_Win.1.txt │ ├── LiveNewGameEnvironmentTests │ ├── test_minefieldGenerator.1.txt │ ├── test_minefieldGenerator.2.txt │ └── test_minefieldGenerator.3.txt │ └── MinefieldAttributesTests │ ├── test_normalize_big.1.txt │ ├── test_normalize_normal.1.txt │ ├── test_normalize_small.1.txt │ ├── test_range_columns.1.txt │ ├── test_range_mines.1.txt │ └── test_range_rows.1.txt ├── MainAppCoreTests.swift ├── MinefieldCoreTests.swift ├── MinefieldStateTests.swift ├── NewGameCoreTests.swift ├── SettingsCoreTests.swift ├── TestPlans └── AllTests.xctestplan └── __Snapshots__ └── MinefieldStateTests ├── test_reveal.1.txt ├── test_reveal.2.txt ├── test_setMarked.1.txt ├── test_setMarked.2.txt ├── test_setMarked.3.txt └── test_setTile.1.txt /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | .DS_Store 92 | -------------------------------------------------------------------------------- /Assets.xcassets/AppIcon.appiconset/AppIcon 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RogyMD/TCAminesweeper/296ed688c1ee8a27060d96bf8f0dc4b791a1e9f2/Assets.xcassets/AppIcon.appiconset/AppIcon 1.png -------------------------------------------------------------------------------- /Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "AppIcon 1.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | } 9 | ], 10 | "info" : { 11 | "author" : "xcode", 12 | "version" : 1 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Assets/README/app_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RogyMD/TCAminesweeper/296ed688c1ee8a27060d96bf8f0dc4b791a1e9f2/Assets/README/app_icon.png -------------------------------------------------------------------------------- /Assets/README/game.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RogyMD/TCAminesweeper/296ed688c1ee8a27060d96bf8f0dc4b791a1e9f2/Assets/README/game.png -------------------------------------------------------------------------------- /Assets/README/high_scores.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RogyMD/TCAminesweeper/296ed688c1ee8a27060d96bf8f0dc4b791a1e9f2/Assets/README/high_scores.png -------------------------------------------------------------------------------- /Assets/README/mac_os.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RogyMD/TCAminesweeper/296ed688c1ee8a27060d96bf8f0dc4b791a1e9f2/Assets/README/mac_os.png -------------------------------------------------------------------------------- /Assets/README/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RogyMD/TCAminesweeper/296ed688c1ee8a27060d96bf8f0dc4b791a1e9f2/Assets/README/settings.png -------------------------------------------------------------------------------- /Colors.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Colors.xcassets/background.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "platform" : "ios", 6 | "reference" : "systemGray3Color" 7 | }, 8 | "idiom" : "universal" 9 | }, 10 | { 11 | "appearances" : [ 12 | { 13 | "appearance" : "luminosity", 14 | "value" : "dark" 15 | } 16 | ], 17 | "color" : { 18 | "platform" : "ios", 19 | "reference" : "systemGray3Color" 20 | }, 21 | "idiom" : "universal" 22 | } 23 | ], 24 | "info" : { 25 | "author" : "xcode", 26 | "version" : 1 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Colors.xcassets/darkBlue4.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x6C", 9 | "green" : "0x04", 10 | "red" : "0x09" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Colors.xcassets/darkRed5.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "7", 9 | "green" : "0", 10 | "red" : "117" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Colors.xcassets/lightBlue6.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "117", 9 | "green" : "117", 10 | "red" : "0" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Colors.xcassets/overlay.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "platform" : "ios", 6 | "reference" : "systemGray2Color" 7 | }, 8 | "idiom" : "universal" 9 | }, 10 | { 11 | "appearances" : [ 12 | { 13 | "appearance" : "luminosity", 14 | "value" : "dark" 15 | } 16 | ], 17 | "color" : { 18 | "platform" : "ios", 19 | "reference" : "systemGray2Color" 20 | }, 21 | "idiom" : "universal" 22 | } 23 | ], 24 | "info" : { 25 | "author" : "xcode", 26 | "version" : 1 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Colors.xcassets/selectedOverlay.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "extended-srgb", 6 | "components" : { 7 | "alpha" : "0.500", 8 | "blue" : "0.698", 9 | "green" : "0.682", 10 | "red" : "0.682" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "extended-srgb", 24 | "components" : { 25 | "alpha" : "0.500", 26 | "blue" : "0.400", 27 | "green" : "0.388", 28 | "red" : "0.388" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Colors.xcassets/shadow.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "platform" : "ios", 6 | "reference" : "secondaryLabelColor" 7 | }, 8 | "idiom" : "universal" 9 | }, 10 | { 11 | "appearances" : [ 12 | { 13 | "appearance" : "luminosity", 14 | "value" : "dark" 15 | } 16 | ], 17 | "color" : { 18 | "platform" : "ios", 19 | "reference" : "secondaryLabelColor" 20 | }, 21 | "idiom" : "universal" 22 | } 23 | ], 24 | "info" : { 25 | "author" : "xcode", 26 | "version" : 1 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | Minesweeper 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | 1.0 21 | CFBundleVersion 22 | 1 23 | LSApplicationCategoryType 24 | public.app-category.games 25 | LSRequiresIPhoneOS 26 | 27 | UIApplicationSupportsIndirectInputEvents 28 | 29 | UILaunchScreen 30 | 31 | UIRequiredDeviceCapabilities 32 | 33 | armv7 34 | 35 | UISupportedInterfaceOrientations 36 | 37 | UIInterfaceOrientationPortrait 38 | UIInterfaceOrientationLandscapeLeft 39 | UIInterfaceOrientationLandscapeRight 40 | 41 | UISupportedInterfaceOrientations~ipad 42 | 43 | UIInterfaceOrientationPortrait 44 | UIInterfaceOrientationPortraitUpsideDown 45 | UIInterfaceOrientationLandscapeLeft 46 | UIInterfaceOrientationLandscapeRight 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | LICENSE AGREEMENT 2 | 3 | Copyright © Igor Bidiniuc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”) to use and redistribute the Software subject to the following restrictions: 6 | 7 | The Software, as available to you through this license, may be used, copied or distributed in its entirety for educational purposes only, including without limitation noncommercial learning, development and research purposes and shall serve exclusively as an educational resource for you and others to learn from; provided that you may use less than the entirety of the Software (e.g., part of the code base) in a commercial project or code base that does not constitute a replica of or otherwise directly compete with the Software in its entirety (collectively, the “Permitted Purpose”). 8 | You may not use, copy or redistribute the Software in its entirety in any manner primarily intended for or directed towards commercial advantage or monetary compensation, including without limitation selling altered or unaltered versions of Software in its entirety, or including advertisements of any kind in altered or unaltered versions of this Software in its entirety. This means that you cannot create an exact replica of the Software in its entirety for commercial purposes or monetary gain. 9 | You may not distribute the Software in its entirety or exact replicas thereof for any purpose through an application store, software repository or similar means. 10 | If you are interested in using or distributing the Software in a manner that is beyond the Permitted Purpose, you should contact the copyright holder to discuss the terms of a potential licensing arrangement. 11 | If you distribute or share the Software or any work based on the Software, the recipient automatically receives a license to use the Software subject to this license and limited to the Permitted Purpose. You may not eliminate any restrictions or impose any further restrictions beyond those set forth herein on the exercise of the rights granted under this license. 12 | The above copyright notice and this license shall be included in all copies or substantial portions of the Software. You may not misrepresent the origin of the Software, including claiming that you wrote the original software. Altered versions of the Software must be plainly marked as such, and must not be misrepresented as being the original Software. 13 | THE SOFTWARE IS PROVIDED “AS IS” AND WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS OF THIS SOFTWARE BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, UNDER ANY LEGAL THEORY, ARISING FROM OR IN RELATION TO THE SOFTWARE OR THE USE THEREOF. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![App Icon](Assets/README/app_icon.png) 2 | [](https://apps.apple.com/app/minesweeper-v1-0/id6738613938) 3 | 4 | ## Minesweeper 5 | 6 | This project is a copy of the Windows game Minesweeper. 7 | 8 | ## About 9 | 10 | Minesweeper is an iOS & Mac Catalyst application built entirely in Swift. The logic is build in the [Composable Architecture](https://github.com/pointfreeco/swift-composable-architecture) and the UI is built in SwiftUI. 11 | 12 | The project shows following use cases: 13 | 14 | * Highly modularized: every feature is isolated into its own module with minimal dependencies between them, allowing us to compile and run features in isolation without building the entire application. 15 | * Fully controlled side effects. Every feature is provided with all the dependencies it needs to do its work, which makes testing very easy. 16 | * The core logic of the application is put into modules named like *Core, and they are kept separate from modules containing UI, which is what allows us to share code across multiple platforms (SwiftUI and UIKit), but could also allow us to share code across iOS, macOS, watchOS and tvOS apps. 17 | * Comprehensive test suite for every feature, including integration tests of many features working in unison, and end-to-end testing of side effects. 18 | 19 | ## Screenshots 20 | 21 | | Game | High Scores | Settings | 22 | |---|---|---| 23 | | | | | 24 | 25 | | Mac Catalyst | 26 | |---| 27 | |![macOS](Assets/README/mac_os.png)| 28 | 29 | ## License 30 | 31 | The source code in this repository may be run and altered for education purposes only and not for commercial purposes. For more information [see LICENSE](https://raw.githubusercontent.com/RogyMD/TCAminesweeper/master/LICENSE). 32 | -------------------------------------------------------------------------------- /TCAminesweeper.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.developer.game-center 6 | 7 | com.apple.security.app-sandbox 8 | 9 | com.apple.security.network.client 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /TCAminesweeper.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /TCAminesweeper.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /TCAminesweeper.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "72272e19d5563cbd38d4afad077e74bbb6c0ffecec5c16fcb00fbe6aff094ed8", 3 | "pins" : [ 4 | { 5 | "identity" : "combine-schedulers", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/pointfreeco/combine-schedulers", 8 | "state" : { 9 | "revision" : "ec62f32d21584214a4b27c8cee2b2ad70ab2c38a", 10 | "version" : "0.11.0" 11 | } 12 | }, 13 | { 14 | "identity" : "swift-case-paths", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/pointfreeco/swift-case-paths", 17 | "state" : { 18 | "revision" : "fc45e7b2cfece9dd80b5a45e6469ffe67fe67984", 19 | "version" : "0.14.1" 20 | } 21 | }, 22 | { 23 | "identity" : "swift-collections", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/apple/swift-collections", 26 | "state" : { 27 | "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", 28 | "version" : "1.1.4" 29 | } 30 | }, 31 | { 32 | "identity" : "swift-composable-architecture", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/pointfreeco/swift-composable-architecture.git", 35 | "state" : { 36 | "revision" : "a518935116b2bada7234f47073159b433d432af1", 37 | "version" : "0.39.1" 38 | } 39 | }, 40 | { 41 | "identity" : "swift-concurrency-extras", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/pointfreeco/swift-concurrency-extras", 44 | "state" : { 45 | "revision" : "479750bd98fac2e813fffcf2af0728b5b0085795", 46 | "version" : "0.1.1" 47 | } 48 | }, 49 | { 50 | "identity" : "swift-custom-dump", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/pointfreeco/swift-custom-dump", 53 | "state" : { 54 | "revision" : "0a5bff05fe01dcd513932ed338a4efad8268b803", 55 | "version" : "0.11.2" 56 | } 57 | }, 58 | { 59 | "identity" : "swift-identified-collections", 60 | "kind" : "remoteSourceControl", 61 | "location" : "https://github.com/pointfreeco/swift-identified-collections", 62 | "state" : { 63 | "revision" : "d01446a78fb768adc9a78cbb6df07767c8ccfc29", 64 | "version" : "0.8.0" 65 | } 66 | }, 67 | { 68 | "identity" : "swift-snapshot-testing", 69 | "kind" : "remoteSourceControl", 70 | "location" : "https://github.com/pointfreeco/swift-snapshot-testing", 71 | "state" : { 72 | "revision" : "c466812aa2e22898f27557e2e780d3aad7a27203", 73 | "version" : "1.8.2" 74 | } 75 | }, 76 | { 77 | "identity" : "xctest-dynamic-overlay", 78 | "kind" : "remoteSourceControl", 79 | "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", 80 | "state" : { 81 | "revision" : "50843cbb8551db836adec2290bb4bc6bac5c1865", 82 | "version" : "0.9.0" 83 | } 84 | } 85 | ], 86 | "version" : 3 87 | } 88 | -------------------------------------------------------------------------------- /TCAminesweeper.xcodeproj/xcshareddata/xcschemes/GameCore.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 44 | 50 | 51 | 57 | 58 | 59 | 60 | 62 | 63 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /TCAminesweeper.xcodeproj/xcshareddata/xcschemes/LiveHighScoreService.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 44 | 50 | 51 | 57 | 58 | 59 | 60 | 62 | 63 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /TCAminesweeper.xcodeproj/xcshareddata/xcschemes/MainAppCore.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 44 | 50 | 51 | 57 | 58 | 59 | 60 | 62 | 63 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /TCAminesweeper.xcodeproj/xcshareddata/xcschemes/TCAminesweeper.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 34 | 35 | 36 | 37 | 39 | 45 | 46 | 47 | 49 | 55 | 56 | 57 | 58 | 59 | 69 | 71 | 77 | 78 | 79 | 80 | 86 | 88 | 94 | 95 | 96 | 97 | 99 | 100 | 103 | 104 | 105 | -------------------------------------------------------------------------------- /TCAminesweeper/Common/Live/LiveAppEnvirontment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LiveAppEnvirontment.swift 3 | // TCAminesweeper 4 | // 5 | // Created by Igor Bidiniuc on 14/03/2021. 6 | // 7 | 8 | import Foundation 9 | import AppCore 10 | 11 | extension AppEnvironment { 12 | static let live = Self( 13 | newGame: .live, 14 | settings: .live, 15 | highScores: .live 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /TCAminesweeper/Common/Live/LiveGameEnvironment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LiveGameEnvironment.swift 3 | // TCAminesweeper 4 | // 5 | // Created by Igor Bidiniuc on 10/03/2021. 6 | // 7 | 8 | import UIKit 9 | import GameCore 10 | 11 | extension GameEnvironment { 12 | static let live = Self( 13 | minefieldEnvironment: .live, 14 | timerScheduler: DispatchQueue(label: "md.rogy.timer", qos: .userInitiated).eraseToAnyScheduler(), 15 | mainQueue: DispatchQueue.main.eraseToAnyScheduler(), 16 | selectionFeedback: { .fireAndForget { UISelectionFeedbackGenerator().selectionChanged() } }, 17 | notificationFeedback: { notification in .fireAndForget { UINotificationFeedbackGenerator().notificationOccurred(notification) } } 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /TCAminesweeper/Common/Live/LiveHighScoreEnvironment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LiveHighScoreEnvironment.swift 3 | // TCAminesweeper 4 | // 5 | // Created by Igor Bidiniuc on 10/03/2021. 6 | // 7 | 8 | import Foundation 9 | import HighScoresCore 10 | 11 | extension HighScoreEnvironment { 12 | static let live = Self(highScoreService: .live) 13 | } 14 | -------------------------------------------------------------------------------- /TCAminesweeper/Common/Live/LiveMainAppEnvironment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LiveMainAppEnvironment.swift 3 | // TCAminesweeper 4 | // 5 | // Created by Igor Bidiniuc on 27/03/2021. 6 | // 7 | 8 | import MainAppCore 9 | 10 | extension MainAppEnvironment { 11 | static let live = Self(app: .live) 12 | } 13 | -------------------------------------------------------------------------------- /TCAminesweeper/Common/Live/LiveMinefieldEnvironment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LiveMinefieldEnvironment.swift 3 | // TCAminesweeper 4 | // 5 | // Created by Igor Bidiniuc on 10/03/2021. 6 | // 7 | 8 | import ComposableArchitecture 9 | import MinefieldCore 10 | 11 | extension MinefieldEnvironment { 12 | static let live = Self( 13 | tileTappedHandler: { index, state in 14 | guard let result = Self.tileTappedHandler(index: index, state: &state) else { return .none } 15 | return Effect(value: result) 16 | } 17 | ) 18 | 19 | static func tileTappedHandler(index: Int, state: inout MinefieldState) -> MinefieldState.Result? { 20 | let tileState = state.grid.content[index] 21 | guard !tileState.isMarked else { return nil } 22 | 23 | guard !tileState.tile.isMine else { 24 | prepareStateForLoss(state: &state, mineIndex: index) 25 | 26 | return .lost 27 | } 28 | 29 | state.reveal(index) 30 | 31 | if tileState.tile.isEmpty { 32 | revealTilesBesideTile(at: index, state: &state) 33 | } 34 | 35 | let isWin = state.gridInfo.mines.count == state.grid.content.count - state.gridInfo.revealed.count 36 | if isWin { 37 | prepareStateForWin(state: &state) 38 | 39 | return .win 40 | } else { 41 | return nil 42 | } 43 | } 44 | 45 | static func prepareStateForLoss(state: inout MinefieldState, mineIndex: Int) { 46 | state.setTile(.explosion, for: mineIndex) 47 | 48 | let unflaggedMines = state.gridInfo.mines.filter { !state.gridInfo.flagged.contains($0) } 49 | state.reveal(unflaggedMines) 50 | state.gridInfo.flagged.forEach { 51 | if !state.gridInfo.mines.contains($0) { 52 | state.setWrongFlag(for: $0) 53 | } 54 | } 55 | } 56 | 57 | static func prepareStateForWin(state: inout MinefieldState) { 58 | let unflaggedMines = state.gridInfo.mines.filter { !state.gridInfo.flagged.contains($0) } 59 | unflaggedMines.forEach { state.setMarked(true, for: $0) } 60 | } 61 | 62 | static func revealTilesBesideTile(at index: Int, state: inout MinefieldState) { 63 | var cache: Set = [] 64 | 65 | func revealTiles(index: Int) { 66 | state.reveal(index) 67 | 68 | state.grid.indexes(beside: index).forEach { neighbour in 69 | state.reveal(neighbour) 70 | 71 | if !cache.contains(neighbour) && state.grid.content[neighbour].tile.isEmpty { 72 | cache.insert(neighbour) 73 | 74 | revealTiles(index: neighbour) 75 | } 76 | } 77 | } 78 | 79 | revealTiles(index: index) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /TCAminesweeper/Common/Live/LiveNewGameEnvironment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LiveNewGameEnvironment.swift 3 | // TCAminesweeper 4 | // 5 | // Created by Igor Bidiniuc on 10/03/2021. 6 | // 7 | import Foundation 8 | import ComposableArchitecture 9 | import NewGameCore 10 | import TCAminesweeperCommon 11 | import MinefieldCore 12 | import TileCore 13 | 14 | extension NewGameEnvironment { 15 | static let live = Self( 16 | minefieldGenerator: { attributes in 17 | Effect(value: Self.minefieldGenerator(attributes, shuffler: .random)) 18 | }, 19 | uuid: UUID.init, 20 | now: Date.init, 21 | game: .live, 22 | settingsService: .live, 23 | highScoreService: .live 24 | ) 25 | } 26 | 27 | extension NewGameEnvironment { 28 | static func minefieldGenerator(_ attributes: MinefieldAttributes, shuffler: Shuffler = .random) -> MinefieldState { 29 | func makeMines(content: inout [Tile]) -> [Int] { 30 | let mines = Array(shuffler.shuffle(Array(content.indices)).prefix(Int(attributes.mines))) 31 | mines.forEach { content[$0] = Tile.mine } 32 | return mines 33 | } 34 | 35 | func makeTiles(mines: [Int], content: inout [Tile]) { 36 | mines.forEach { 37 | Grid(attributes: attributes) 38 | .indexes(beside: $0) 39 | .forEach { 40 | guard !mines.contains($0) else { return } 41 | content[$0] = content[$0].next 42 | } 43 | } 44 | } 45 | 46 | var tiles = Array(repeating: Tile.empty, count: Int(attributes.rows * attributes.columns)) 47 | 48 | let mines = makeMines(content: &tiles) 49 | makeTiles(mines: mines, content: &tiles) 50 | let content = tiles.enumerated().map { TileState(id: $0, tile: $1, isHidden: true, isMarked: false) } 51 | let grid = Grid(attributes: attributes, content: content) 52 | 53 | return MinefieldState(grid: grid, gridInfo: GridInfo(mines: Set(mines))) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /TCAminesweeper/Common/Live/LiveSettingsEnvironment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LiveSettingsEnvironment.swift 3 | // TCAminesweeper 4 | // 5 | // Created by Igor Bidiniuc on 10/03/2021. 6 | // 7 | 8 | import Foundation 9 | import SettingsCore 10 | 11 | extension SettingsEnvironment { 12 | static let live = Self(settingsService: .live) 13 | } 14 | -------------------------------------------------------------------------------- /TCAminesweeper/Common/Models/Difficulty.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Difficulty.swift 3 | // TCAminesweeper 4 | // 5 | // Created by Igor Bidiniuc on 14/03/2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public enum Difficulty: String, CaseIterable, Hashable, Codable { 11 | case easy = "Easy" 12 | case normal = "Normal" 13 | case hard = "Hard" 14 | case custom = "Custom" 15 | 16 | public var id: String { rawValue } 17 | public var isCustom: Bool { self == .custom } 18 | public var title: String { rawValue } 19 | } 20 | -------------------------------------------------------------------------------- /TCAminesweeper/Common/Models/Grid.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Grid.swift 3 | // TCAminesweeper 4 | // 5 | // Created by Igor Bidiniuc on 07/03/2021. 6 | // 7 | 8 | import Foundation 9 | import ComposableArchitecture 10 | 11 | public struct Grid: Equatable where Content: Identifiable & Equatable { 12 | public let rows: Int 13 | public let columns: Int 14 | public var content: IdentifiedArrayOf 15 | public var isValid: Bool { content.count == rows * columns } 16 | 17 | public init( 18 | rows: Int, 19 | columns: Int, 20 | content: [Content] = [] 21 | ) { 22 | self.rows = rows 23 | self.columns = columns 24 | self.content = IdentifiedArray(uniqueElements: content) 25 | } 26 | } 27 | 28 | public extension Grid { 29 | struct Position: Hashable { 30 | public var row: Int 31 | public var column: Int 32 | } 33 | 34 | func indexes(beside index: Int) -> [Int] { 35 | positions(beside: position(for: index)) 36 | .map { self.index(for: $0) } 37 | } 38 | 39 | func index(for position: Position) -> Int { 40 | return position.row * columns + position.column 41 | } 42 | 43 | func position(for index: Int) -> Position { 44 | Position(row: index / columns, column: index % columns) 45 | } 46 | 47 | func positions(beside position: Position) -> Set { 48 | let atTopMargin = position.row == 0 49 | let atBottomMargin = position.row == (rows - 1) 50 | let atLeadingMargin = position.column == 0 51 | let atTrailingMargin = position.column == (columns - 1) 52 | 53 | var positions: [Position] = [] 54 | 55 | if !atLeadingMargin { 56 | positions.append(position.decrease(\.column)) 57 | 58 | if !atTopMargin { positions.append(position.decrease(\.row).decrease(\.column)) } 59 | if !atBottomMargin { positions.append(position.increase(\.row).decrease(\.column)) } 60 | } 61 | 62 | if !atTopMargin { positions.append(position.decrease(\.row)) } 63 | 64 | if !atBottomMargin { positions.append(position.increase(\.row)) } 65 | 66 | if !atTrailingMargin { 67 | positions.append(position.increase(\.column)) 68 | 69 | if !atTopMargin { positions.append(position.decrease(\.row).increase(\.column)) } 70 | if !atBottomMargin { positions.append(position.increase(\.row).increase(\.column)) } 71 | } 72 | 73 | return Set(positions) 74 | } 75 | } 76 | 77 | private extension Grid.Position { 78 | func decrease(_ kp: WritableKeyPath) -> Self { 79 | var position = self 80 | position[keyPath: kp] -= 1 81 | return position 82 | } 83 | 84 | func increase(_ kp: WritableKeyPath) -> Self { 85 | var position = self 86 | position[keyPath: kp] += 1 87 | return position 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /TCAminesweeper/Common/Models/GridInfo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GridInfo.swift 3 | // TCAminesweeper 4 | // 5 | // Created by Igor Bidiniuc on 19/03/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct GridInfo: Equatable { 11 | public let mines: Set 12 | public var flagged: Set = [] 13 | public var revealed: Set = [] 14 | 15 | public init( 16 | mines: Set, 17 | flagged: Set = [], 18 | revealed: Set = [] 19 | ) { 20 | self.mines = mines 21 | self.flagged = flagged 22 | self.revealed = revealed 23 | } 24 | } 25 | 26 | extension GridInfo: CustomStringConvertible { 27 | public var description: String { 28 | " { 11 | public let shuffle: ([T]) -> [T] 12 | 13 | public init(shuffle: @escaping ([T]) -> [T]) { 14 | self.shuffle = shuffle 15 | } 16 | } 17 | 18 | public extension Shuffler where T == Int { 19 | static let random = Self(shuffle: { $0.shuffled() }) 20 | } 21 | -------------------------------------------------------------------------------- /TCAminesweeper/Common/Models/UserHighScore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserHighScore.swift 3 | // TCAminesweeper 4 | // 5 | // Created by Igor Bidiniuc on 11/03/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct UserHighScore: Codable, Equatable { 11 | public let id: UUID 12 | public var score: Int 13 | public var userName: String? 14 | public var date: Date 15 | 16 | public init( 17 | id: UUID, 18 | score: Int, 19 | userName: String?, 20 | date: Date = Date() 21 | ) { 22 | self.id = id 23 | self.score = score 24 | self.userName = userName 25 | self.date = date 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /TCAminesweeper/Common/Models/UserSettings.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserSettings.swift 3 | // TCAminesweeper 4 | // 5 | // Created by Igor Bidiniuc on 10/03/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct UserSettings: Codable, Equatable { 11 | public var difficulty: Difficulty 12 | public var minefieldAttributes: MinefieldAttributes 13 | 14 | public init(minefieldAttributes: MinefieldAttributes) { 15 | self.difficulty = .custom 16 | self.minefieldAttributes = minefieldAttributes 17 | } 18 | 19 | public init(otherThanCustom difficulty: Difficulty) { 20 | guard let attributes = difficulty.minefieldAttributes else { 21 | fatalError("The difficulty \(String(describing: difficulty)) doesn't have predefined settings") 22 | } 23 | 24 | self.difficulty = difficulty 25 | self.minefieldAttributes = attributes 26 | } 27 | } 28 | 29 | public extension UserSettings { 30 | static let `default` = Self( 31 | otherThanCustom: .easy 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /TCAminesweeper/Constants/Colors.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Colors.swift 3 | // TCAminesweeper 4 | // 5 | // Created by Igor Bidiniuc on 04/03/2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public extension Color { 11 | static var overlay: Color { 12 | return Color("overlay") 13 | } 14 | 15 | static var selectedOverlay: Color { 16 | return Color("selectedOverlay") 17 | } 18 | 19 | static var background: Color { 20 | return Color("background") 21 | } 22 | 23 | static var shadow: Color { 24 | Color("shadow") 25 | } 26 | 27 | static var darkBlue4: Color { 28 | Color("darkBlue4") 29 | } 30 | 31 | static var darkRed5: Color { 32 | Color("darkRed5") 33 | } 34 | 35 | static var lightBlue6: Color { 36 | Color("lightBlue6") 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /TCAminesweeper/Constants/Fonts.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Fonts.swift 3 | // TCAminesweeper 4 | // 5 | // Created by Igor Bidiniuc on 06/03/2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public extension Font { 11 | static var header: Font { 12 | Font( 13 | UIFont.monospacedSystemFont( 14 | ofSize: UIFont.preferredFont(forTextStyle: .title1).pointSize, 15 | weight: .semibold) 16 | ) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /TCAminesweeper/Core/AppCore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppCore.swift 3 | // TCAminesweeper 4 | // 5 | // Created by Igor Bidiniuc on 13/03/2021. 6 | // 7 | 8 | import Foundation 9 | import ComposableArchitecture 10 | import SettingsService 11 | import SettingsCore 12 | import HighScoreService 13 | import HighScoresCore 14 | import GameCore 15 | import NewGameCore 16 | 17 | public enum SheetState: Int, Identifiable { 18 | case settings 19 | case highScores 20 | 21 | public var id: Int { rawValue } 22 | } 23 | 24 | public struct AppState: Equatable { 25 | public var newGame: NewGameState = NewGameState() 26 | public var sheet: SheetState? = nil 27 | public var settings: SettingsState? = nil 28 | public var highScores: HighScoreState? = nil 29 | 30 | var game: GameState? { newGame.game } 31 | 32 | public init( 33 | newGame: NewGameState = NewGameState(), 34 | sheet: SheetState? = nil, 35 | settings: SettingsState? = nil, 36 | highScores: HighScoreState? = nil 37 | ) { 38 | self.newGame = newGame 39 | self.sheet = sheet 40 | self.settings = settings 41 | self.highScores = highScores 42 | } 43 | } 44 | 45 | public enum AppAction: Equatable { 46 | case newGameAction(NewGameAction) 47 | case settingsAction(SettingsAction) 48 | case highScoresAction(HighScoreAction) 49 | case settingsButtonTapped 50 | case showSettings(SettingsState) 51 | case highScoresButtonTapped 52 | case showHighScores(HighScoreState) 53 | case dismiss 54 | } 55 | 56 | public struct AppEnvironment { 57 | public var newGame: NewGameEnvironment 58 | public var settings: SettingsEnvironment 59 | public var highScores: HighScoreEnvironment 60 | 61 | var settingsService: SettingsService { settings.settingsService } 62 | var highScoreService: HighScoreService { highScores.highScoreService } 63 | 64 | public init( 65 | newGame: NewGameEnvironment, 66 | settings: SettingsEnvironment, 67 | highScores: HighScoreEnvironment 68 | ) { 69 | self.newGame = newGame 70 | self.settings = settings 71 | self.highScores = highScores 72 | } 73 | } 74 | 75 | public let appReducer = Reducer.combine( 76 | newGameReducer 77 | .pullback( 78 | state: \.newGame, 79 | action: /AppAction.newGameAction, 80 | environment: \.newGame 81 | ), 82 | 83 | settingsReducer 84 | .optional() 85 | .pullback( 86 | state: \.settings, 87 | action: /AppAction.settingsAction, 88 | environment: \.settings), 89 | 90 | highScoreReducer 91 | .optional() 92 | .pullback( 93 | state: \.highScores, 94 | action: /AppAction.highScoresAction, 95 | environment: \.highScores 96 | ), 97 | 98 | Reducer { state, action, environment in 99 | switch action { 100 | case .settingsAction(.newGameButtonTapped): 101 | return .merge( 102 | Effect(value: .newGameAction(.startNewGame)), 103 | Effect(value: .dismiss) 104 | ) 105 | 106 | case .settingsButtonTapped: 107 | return environment.settingsService 108 | .userSettings() 109 | .map { .showSettings(SettingsState(userSettings: $0)) } 110 | 111 | case let .showSettings(settings): 112 | state.settings = settings 113 | state.sheet = .settings 114 | return Effect(value: .newGameAction(.gameAction(.onDisappear))) 115 | 116 | case .highScoresButtonTapped: 117 | let difficulty = (state.game?.difficulty ?? .custom).isCustom ? 118 | .easy : 119 | (state.game?.difficulty ?? .easy) 120 | 121 | return environment.highScoreService 122 | .scores(difficulty) 123 | .map { .showHighScores(HighScoreState(difficulty: difficulty, scores: $0)) } 124 | 125 | case let .showHighScores(highScores): 126 | state.highScores = highScores 127 | state.sheet = .highScores 128 | return Effect(value: .newGameAction(.gameAction(.onDisappear))) 129 | 130 | case .highScoresAction(.cancelButtonTapped), 131 | .settingsAction(.cancelButtonTapped): 132 | state.sheet = nil 133 | return .none 134 | 135 | case .dismiss: 136 | state.sheet = nil 137 | state.settings = nil 138 | state.highScores = nil 139 | return Effect(value: .newGameAction(.gameAction(.onAppear))) 140 | 141 | case .settingsAction(_), 142 | .highScoresAction(_), 143 | .newGameAction(_): 144 | return .none 145 | } 146 | } 147 | ) 148 | 149 | #if DEBUG 150 | 151 | public extension AppEnvironment { 152 | static func mock( 153 | newGame: NewGameEnvironment = .mock(), 154 | settings: SettingsEnvironment = .mock(), 155 | highScores: HighScoreEnvironment = .mock() 156 | ) -> Self { 157 | Self( 158 | newGame: newGame, 159 | settings: settings, 160 | highScores: highScores 161 | ) 162 | } 163 | 164 | static let preview = Self( 165 | newGame: .preview, 166 | settings: .preview, 167 | highScores: .preview 168 | ) 169 | } 170 | 171 | #endif 172 | -------------------------------------------------------------------------------- /TCAminesweeper/Core/HeaderCore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HeaderCore.swift 3 | // TCAminesweeper 4 | // 5 | // Created by Igor Bidiniuc on 06/03/2021. 6 | // 7 | 8 | import Foundation 9 | import ComposableArchitecture 10 | 11 | public struct HeaderState: Equatable { 12 | public var leadingText: String 13 | public var centerText: String 14 | public var trailingText: String 15 | } 16 | 17 | public enum HeaderAction: Equatable { 18 | case buttonTapped 19 | } 20 | 21 | public typealias HeaderEnvironment = () 22 | 23 | public let headerReducer = Reducer.empty 24 | -------------------------------------------------------------------------------- /TCAminesweeper/Core/HighScoreCore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HighScoreCore.swift 3 | // TCAminesweeper 4 | // 5 | // Created by Igor Bidiniuc on 10/03/2021. 6 | // 7 | 8 | import ComposableArchitecture 9 | import HighScoreService 10 | import TCAminesweeperCommon 11 | 12 | public struct HighScoreState: Equatable { 13 | public let categories: [Difficulty] = [.easy, .normal, .hard] 14 | public var difficulty: Difficulty 15 | public fileprivate(set) var scores: IdentifiedArrayOf> = [] 16 | 17 | public init( 18 | difficulty: Difficulty, 19 | scores: [UserHighScore] = [] 20 | ) { 21 | self.difficulty = difficulty 22 | self.scores = IdentifiedArray(uniqueElements: scores.enumerated().map { Identified($1, id: $0) }) 23 | } 24 | } 25 | 26 | public enum HighScoreAction: Equatable { 27 | case difficultyChanged(Difficulty) 28 | case updateScores([UserHighScore]) 29 | case cancelButtonTapped 30 | case loadScores 31 | case scoreAction(Int, Never) 32 | } 33 | 34 | public struct HighScoreEnvironment { 35 | public var highScoreService: HighScoreService 36 | 37 | public init(highScoreService: HighScoreService) { 38 | self.highScoreService = highScoreService 39 | } 40 | } 41 | 42 | public let highScoreReducer = Reducer { state, action, environment in 43 | switch action { 44 | case let .difficultyChanged(difficulty): 45 | state.difficulty = difficulty 46 | return Effect(value: .loadScores) 47 | 48 | case let .updateScores(scores): 49 | state.scores = IdentifiedArray(uniqueElements: scores.sorted(by: { $0.score < $1.score }).enumerated().map { Identified($1, id: $0) }) 50 | return .none 51 | 52 | case .loadScores: 53 | return environment.highScoreService.scores(state.difficulty) 54 | .map { .updateScores($0) } 55 | 56 | case .scoreAction(_, _), .cancelButtonTapped: 57 | return .none 58 | } 59 | } 60 | 61 | #if DEBUG 62 | 63 | public extension HighScoreEnvironment { 64 | static func mock(highScoreService: HighScoreService = .mock()) -> Self { 65 | Self(highScoreService: highScoreService) 66 | } 67 | 68 | static let preview = Self( 69 | highScoreService: .preview 70 | ) 71 | } 72 | 73 | #endif 74 | -------------------------------------------------------------------------------- /TCAminesweeper/Core/MainAppCore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainAppCore.swift 3 | // TCAminesweeper 4 | // 5 | // Created by Igor Bidiniuc on 27/03/2021. 6 | // 7 | 8 | import ComposableArchitecture 9 | import AppCore 10 | 11 | public struct MainAppState: Equatable { 12 | public internal(set) var app: AppState 13 | 14 | public init(app: AppState = .init()) { 15 | self.app = app 16 | } 17 | } 18 | 19 | public enum MainAppAction: Equatable { 20 | case appAction(AppAction) 21 | case newGameCommand 22 | case settingsCommand 23 | } 24 | 25 | public struct MainAppEnvironment { 26 | public var app: AppEnvironment 27 | 28 | public init(app: AppEnvironment) { 29 | self.app = app 30 | } 31 | } 32 | 33 | public let mainAppReducer: Reducer = .combine( 34 | appReducer.pullback( 35 | state: \.app, 36 | action: /MainAppAction.appAction, 37 | environment: \.app 38 | ), 39 | 40 | Reducer { state, action, environment in 41 | switch action { 42 | case .newGameCommand: 43 | if state.app.settings != nil { 44 | return Effect(value: .appAction(.settingsAction(.newGameButtonTapped))) 45 | } else { 46 | return Effect(value: .appAction(.newGameAction(.startNewGame))) 47 | } 48 | 49 | case .settingsCommand: 50 | return Effect(value: .appAction(.settingsButtonTapped)) 51 | 52 | case .appAction(_): 53 | return .none 54 | } 55 | } 56 | ) 57 | 58 | #if DEBUG 59 | 60 | extension MainAppEnvironment { 61 | static func mock(app: AppEnvironment = .mock()) -> MainAppEnvironment { 62 | Self(app: app) 63 | } 64 | } 65 | 66 | #endif 67 | -------------------------------------------------------------------------------- /TCAminesweeper/Core/MinefieldCore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MinefieldCore.swift 3 | // TCAminesweeper 4 | // 5 | // Created by Igor Bidiniuc on 06/03/2021. 6 | // 7 | 8 | import ComposableArchitecture 9 | import TCAminesweeperCommon 10 | import TileCore 11 | 12 | public struct MinefieldState: Equatable { 13 | public enum Result: Equatable { 14 | case win 15 | case lost 16 | 17 | public var isWin: Bool { self == .win } 18 | } 19 | 20 | public var grid: Grid 21 | public var gridInfo: GridInfo 22 | public var result: Result? = nil 23 | 24 | public init( 25 | grid: Grid, 26 | gridInfo: GridInfo 27 | ) { 28 | assert(grid.isValid, "invalid grid: \(grid)") 29 | self.grid = grid 30 | self.gridInfo = gridInfo 31 | } 32 | 33 | public init( 34 | grid: Grid 35 | ) { 36 | assert(grid.isValid, "invalid grid: \(grid)") 37 | self.grid = grid 38 | let mines = Set(grid.content.indices.filter { grid.content[$0].tile.isMine } ) 39 | let flagged = Set(grid.content.indices.filter { grid.content[$0].isMarked } ) 40 | let revealed = Set(grid.content.indices.filter { grid.content[$0].isHidden == false } ) 41 | self.gridInfo = GridInfo(mines: mines, flagged: flagged, revealed: revealed) 42 | } 43 | } 44 | 45 | extension MinefieldState: CustomStringConvertible { 46 | public var description: String { 47 | "" 52 | } 53 | } 54 | 55 | public enum MinefieldAction: Equatable { 56 | case tile(Int, TileAction) 57 | case toogleMark(Int) 58 | case resultChanged(MinefieldState.Result?) 59 | } 60 | 61 | public struct MinefieldEnvironment { 62 | public var tileTappedHandler: (Int, inout MinefieldState) -> Effect 63 | 64 | public init( 65 | tileTappedHandler: @escaping (Int, inout MinefieldState) -> Effect 66 | ) { 67 | self.tileTappedHandler = tileTappedHandler 68 | } 69 | } 70 | 71 | public let minefieldReducer: Reducer = tileReducer 72 | .forEach( 73 | state: \.grid.content, 74 | action: /MinefieldAction.tile, 75 | environment: { _ in TileEnvironment() } 76 | ) 77 | .combined(with: Reducer { state, action, environment in 78 | switch action { 79 | case let .tile(index, .tapped): 80 | return environment.tileTappedHandler(index, &state) 81 | .map { .resultChanged($0) } 82 | 83 | case let .tile(index, .longPressed): 84 | return Effect(value: .toogleMark(index)) 85 | 86 | case let .toogleMark(index): 87 | let isMarked = state.grid.content[index].isMarked 88 | state.setMarked(!isMarked, for: index) 89 | return .none 90 | 91 | case let .resultChanged(result): 92 | state.result = result 93 | return.none 94 | } 95 | }) 96 | 97 | public extension MinefieldState { 98 | mutating func reveal(_ index: Int) { 99 | self.reveal([index]) 100 | } 101 | 102 | mutating func reveal(_ indexes: Set) { 103 | gridInfo.revealed.formUnion(indexes) 104 | gridInfo.flagged.subtract(indexes) 105 | indexes.forEach { grid.content[$0].isHidden = false } 106 | } 107 | 108 | mutating func setTile(_ newTile: Tile, for index: Int) { 109 | grid.content[index].tile = newTile 110 | } 111 | 112 | mutating func setMarked(_ isMarked: Bool, for index: Int) { 113 | grid.content[index].isMarked = isMarked 114 | if isMarked { 115 | gridInfo.flagged.insert(index) 116 | } else { 117 | gridInfo.flagged.remove(index) 118 | } 119 | } 120 | 121 | mutating func setWrongFlag(for index: Int) { 122 | var tileState = grid.content[index] 123 | tileState.isMarked = true 124 | tileState.tile = .mine 125 | tileState.isHidden = false 126 | grid.content[index] = tileState 127 | } 128 | } 129 | 130 | #if DEBUG 131 | 132 | public extension MinefieldState { 133 | static func randomState(rows: Int = 10, columns: Int = 10) -> Self { 134 | return Self(grid: .randomGrid(rows: rows, columns: columns)) 135 | } 136 | } 137 | 138 | public extension MinefieldEnvironment { 139 | static func mock(tileTappedHandler: @escaping (Int, inout MinefieldState) -> Effect = { _, _ in fatalError() }) -> Self { 140 | Self(tileTappedHandler: tileTappedHandler) 141 | } 142 | 143 | static let preview = Self.mock(tileTappedHandler: { _, _ in .none }) 144 | } 145 | 146 | public extension MinefieldState { 147 | static let oneMine = Self(grid: .oneMine, gridInfo: GridInfo(mines: [1])) 148 | static let twoMines = Self(grid: .twoMines, gridInfo: GridInfo(mines: [1, 3])) 149 | } 150 | 151 | public extension Grid where Content == TileState { 152 | static let oneMine = Self( 153 | rows: 2, 154 | columns: 2, 155 | content: [ 156 | TileState(id: 0, tile: .one), 157 | TileState(id: 1, tile: .mine), 158 | TileState(id: 2, tile: .one), 159 | TileState(id: 3, tile: .one), 160 | ] 161 | ) 162 | static let twoMines = Self( 163 | rows: 2, 164 | columns: 2, 165 | content: [ 166 | TileState(id: 0, tile: .one), 167 | TileState(id: 1, tile: .mine), 168 | TileState(id: 2, tile: .one), 169 | TileState(id: 3, tile: .mine), 170 | ] 171 | ) 172 | 173 | static func randomGrid( 174 | rows: Int = (3..<20).randomElement()!, 175 | columns: Int = (3..<20).randomElement()! 176 | ) -> Self { 177 | let content = Array(0.. Effect 40 | let uuid: () -> UUID 41 | let now: () -> Date 42 | var game: GameEnvironment 43 | var settingsService: SettingsService 44 | var highScoreService: HighScoreService 45 | 46 | public init( 47 | minefieldGenerator: @escaping (MinefieldAttributes) -> Effect, 48 | uuid: @escaping () -> UUID, 49 | now: @escaping () -> Date, 50 | game: GameEnvironment, 51 | settingsService: SettingsService, 52 | highScoreService: HighScoreService 53 | ) { 54 | self.minefieldGenerator = minefieldGenerator 55 | self.uuid = uuid 56 | self.now = now 57 | self.game = game 58 | self.settingsService = settingsService 59 | self.highScoreService = highScoreService 60 | } 61 | } 62 | 63 | public let newGameReducer: Reducer = .combine( 64 | gameReducer 65 | .optional() 66 | .pullback( 67 | state: \.game, 68 | action: /NewGameAction.gameAction, 69 | environment: \.game), 70 | 71 | Reducer { state, action, environment in 72 | switch action { 73 | case .gameAction(.headerAction(.buttonTapped)): 74 | return Effect(value: .startNewGame) 75 | 76 | case let .gameAction(.gameStateChanged(.over(score: score))): 77 | guard let score = score, 78 | let difficulty = state.game?.difficulty 79 | else { return .none } 80 | 81 | return environment.highScoreService.isScoreInTop10(difficulty, score) 82 | .compactMap { $0 ? .showAlert : nil } 83 | .eraseToEffect() 84 | 85 | case .startNewGame: 86 | return environment.settingsService.userSettings() 87 | .flatMap { settings in 88 | environment.minefieldGenerator(settings.minefieldAttributes) 89 | .map { GameState(difficulty: settings.difficulty, minefieldState: $0) } 90 | } 91 | .map(NewGameAction.newGame) 92 | .eraseToEffect() 93 | 94 | case let .newGame(game): 95 | state.game = game 96 | return Effect(value: .gameAction(.startNewGame(game.minefieldState))) 97 | 98 | case .showAlert: 99 | state.showsHighScoreAlert = true 100 | return .none 101 | 102 | case let .alertActionButtonTapped(name): 103 | guard let game = state.game, 104 | case let .over(score) = game.gameState, 105 | let highScore = score 106 | else { return .none } 107 | 108 | let userScore = UserHighScore( 109 | id: environment.uuid(), 110 | score: highScore, 111 | userName: name, 112 | date: environment.now() 113 | ) 114 | 115 | return environment.highScoreService.saveScore(userScore, game.difficulty) 116 | .fireAndForget() 117 | .eraseToEffect() 118 | 119 | case .dismissAlert: 120 | state.showsHighScoreAlert = false 121 | return .none 122 | 123 | case .gameAction(_): 124 | return .none 125 | } 126 | } 127 | ) 128 | 129 | #if DEBUG 130 | 131 | public extension NewGameEnvironment { 132 | static func mock( 133 | minefieldGenerator: @escaping (MinefieldAttributes) -> Effect = {_ in fatalError()}, 134 | uuid: @escaping () -> UUID = { fatalError() }, 135 | now: @escaping () -> Date = { fatalError() }, 136 | game: GameEnvironment = .mock(), 137 | settingsService: SettingsService = .mock(), 138 | highScoreService: HighScoreService = .mock() 139 | ) -> Self { 140 | Self( 141 | minefieldGenerator: minefieldGenerator, 142 | uuid: uuid, 143 | now: now, 144 | game: game, 145 | settingsService: settingsService, 146 | highScoreService: highScoreService 147 | ) 148 | } 149 | 150 | static let preview = Self.mock(minefieldGenerator: { _ in .none }) 151 | } 152 | 153 | #endif 154 | -------------------------------------------------------------------------------- /TCAminesweeper/Core/SettingsCore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsCore.swift 3 | // TCAminesweeper 4 | // 5 | // Created by Igor Bidiniuc on 08/03/2021. 6 | // 7 | 8 | import ComposableArchitecture 9 | import Foundation 10 | import SettingsService 11 | import TCAminesweeperCommon 12 | 13 | public struct SettingsState: Equatable { 14 | @BindableState public var userSettings: UserSettings 15 | public let difficulties: [Difficulty] = [.easy, .normal, .hard, .custom] 16 | public var difficulty: Difficulty { 17 | get { userSettings.difficulty } 18 | set { userSettings.difficulty = newValue } 19 | } 20 | public var minefieldAttributes: MinefieldAttributes { 21 | get { userSettings.minefieldAttributes } 22 | set { userSettings.minefieldAttributes = newValue } 23 | } 24 | 25 | public init(userSettings: UserSettings) { 26 | self.userSettings = userSettings 27 | } 28 | } 29 | 30 | public enum SettingsAction: Equatable { 31 | case cancelButtonTapped 32 | case newGameButtonTapped 33 | case binding(BindingAction) 34 | case saveSettings 35 | } 36 | 37 | public struct SettingsEnvironment { 38 | public var settingsService: SettingsService 39 | 40 | public init(settingsService: SettingsService) { 41 | self.settingsService = settingsService 42 | } 43 | } 44 | 45 | public let settingsReducer = Reducer { state, action, environment in 46 | switch action { 47 | 48 | case .binding(\.$userSettings.difficulty): 49 | if let attributes = state.difficulty.minefieldAttributes { 50 | state.minefieldAttributes = attributes 51 | } 52 | return Effect(value: .saveSettings) 53 | 54 | case .saveSettings: 55 | return environment.settingsService.saveUserSettings(state.userSettings) 56 | .fireAndForget() 57 | .eraseToEffect() 58 | 59 | case .binding(\.$userSettings.minefieldAttributes.rows), 60 | .binding(\.$userSettings.minefieldAttributes.columns), 61 | .binding(\.$userSettings.minefieldAttributes.mines): 62 | 63 | state.minefieldAttributes.normalize() 64 | return Effect(value: .saveSettings) 65 | 66 | case .binding(_): 67 | return Effect(value: .saveSettings) 68 | 69 | case .cancelButtonTapped, .newGameButtonTapped: 70 | return .none 71 | } 72 | } 73 | 74 | #if DEBUG 75 | 76 | public extension SettingsEnvironment { 77 | static func mock(settingsService: SettingsService = .mock()) -> Self { 78 | SettingsEnvironment(settingsService: settingsService) 79 | } 80 | 81 | static let preview = Self(settingsService: .preview) 82 | } 83 | 84 | #endif 85 | -------------------------------------------------------------------------------- /TCAminesweeper/Core/TileCore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TileCore.swift 3 | // TCAminesweeper 4 | // 5 | // Created by Igor Bidiniuc on 06/03/2021. 6 | // 7 | 8 | import Foundation 9 | import ComposableArchitecture 10 | 11 | public enum Tile: Int, CaseIterable { 12 | case explosion = -2 13 | case mine = -1 14 | case empty = 0 15 | case one = 1 16 | case two = 2 17 | case three = 3 18 | case four = 4 19 | case five = 5 20 | case six = 6 21 | case seven = 7 22 | case eight = 8 23 | 24 | public var isMine: Bool { self == .mine } 25 | public var isEmpty: Bool { self == .empty } 26 | public var next: Tile { Tile(rawValue: rawValue + 1) ?? .explosion } 27 | } 28 | 29 | public struct TileState: Equatable, Identifiable { 30 | 31 | public let id: Int 32 | public var tile: Tile 33 | public var isHidden: Bool 34 | public var isMarked: Bool 35 | 36 | public init( 37 | id: Int = UUID().hashValue, 38 | tile: Tile, 39 | isHidden: Bool = true, 40 | isMarked: Bool = false 41 | ) { 42 | self.id = id 43 | self.tile = tile 44 | self.isHidden = isHidden 45 | self.isMarked = isMarked 46 | } 47 | } 48 | 49 | public enum TileAction: Equatable { 50 | case tapped 51 | case longPressed 52 | } 53 | 54 | public struct TileEnvironment { 55 | public init() {} 56 | } 57 | 58 | public let tileReducer = Reducer.empty 59 | 60 | extension Tile: CustomStringConvertible { 61 | public static func fromString(_ string: String) -> Self? { 62 | switch string { 63 | case "💥": 64 | return .explosion 65 | case "💣": 66 | return .mine 67 | case " ": 68 | return .empty 69 | default: 70 | guard let intValue = Int(string), let tile = Tile(rawValue: intValue) else { return nil } 71 | return tile 72 | } 73 | } 74 | 75 | public var description: String { 76 | switch self { 77 | case .explosion: 78 | return "💥" 79 | case .mine: 80 | return "💣" 81 | case .empty: 82 | return " " 83 | case .one, .two, .three, .four, .five, .six, .seven, .eight: 84 | return String(rawValue) 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /TCAminesweeper/Extensions/Difficulty+MinefieldAttributes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Difficulty+MinefieldAttributes.swift 3 | // TCAminesweeper 4 | // 5 | // Created by Igor Bidiniuc on 14/03/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension Difficulty { 11 | var minefieldAttributes: MinefieldAttributes? { 12 | switch self { 13 | case .easy: 14 | return .init(rows: 9, columns: 9, mines: 10) 15 | case .normal: 16 | return .init(rows: 16, columns: 16, mines: 40) 17 | case .hard: 18 | return .init(rows: 16, columns: 30, mines: 99) 19 | case .custom: 20 | return nil 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /TCAminesweeper/Extensions/Grid+String.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Grid+String.swift 3 | // TCAminesweeper 4 | // 5 | // Created by Igor Bidiniuc on 20/03/2021. 6 | // 7 | 8 | import Foundation 9 | import TileCore 10 | import TCAminesweeperCommon 11 | 12 | extension Grid: @retroactive CustomStringConvertible where Content == TileState { 13 | public var description: String { 14 | "\n" + String(repeating: "_", count: columns + 1) + "\n" + 15 | content.enumerated().reduce("") { result, arg1 in 16 | let (index, state) = arg1 17 | let tile = state.tile 18 | let position = self.position(for: index) 19 | if position.row == 0 && position.column == 0 { return result + "|\(tile.description)" } 20 | else if position.column == 0 && position.row > 0 { return result + "\n|\(tile.description)" } 21 | else if position.column == columns - 1 { return result + "\(tile.description)|" } 22 | else { return result + tile.description } 23 | } + 24 | "\n" + String(repeating: "_", count: columns + 1) + "\n" 25 | } 26 | } 27 | 28 | extension Grid where Content == TileState { 29 | public static func fromDescription(_ description: String) -> Self? { 30 | let rows = description 31 | .split(separator: "\n") // split in rows 32 | .filter { $0.first == "|" } // filter rows that don't contain tiles 33 | .map { $0.compactMap { Tile.fromString(String($0)) } } // map String to Tile 34 | guard let columns = rows.first?.count else { return nil } 35 | 36 | var id = 0 37 | func stateIdFrom0() -> Int { defer { id += 1 }; return id } 38 | 39 | return Self( 40 | rows: rows.count, 41 | columns: columns, 42 | content: description.reduce([], { result, char in 43 | guard let tile = Tile.fromString(String(char)) else { return result } 44 | return result + [ TileState(id: stateIdFrom0(), tile: tile, isHidden: true, isMarked: false) ] 45 | }) 46 | ) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /TCAminesweeper/Extensions/MinefieldAttributes+Normalize.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MinefieldAttributes+Normalize.swift 3 | // TCAminesweeper 4 | // 5 | // Created by Igor Bidiniuc on 14/03/2021. 6 | // 7 | 8 | import Foundation 9 | import TCAminesweeperCommon 10 | 11 | extension MinefieldAttributes { 12 | private enum Maximum { 13 | static let rows: UInt = 30 14 | static let columns: UInt = 30 15 | static func mines(rows: UInt, columns: UInt) -> UInt { 16 | return rows * columns - 1 17 | } 18 | } 19 | 20 | private enum Minimum { 21 | static let rows: UInt = 3 22 | static let columns: UInt = 3 23 | static let mines: UInt = 1 24 | } 25 | 26 | mutating func normalize() { 27 | rows = min(max(rows, Minimum.rows), Maximum.rows) 28 | columns = min(max(columns, Minimum.columns), Maximum.columns) 29 | mines = min(max(mines, Minimum.mines), Maximum.mines(rows: rows, columns: columns)) 30 | } 31 | } 32 | 33 | public extension MinefieldAttributes { 34 | func range(forKeyPath keyPath: KeyPath) -> ClosedRange { 35 | switch keyPath { 36 | case \.rows: 37 | return Minimum.rows...Maximum.rows 38 | case \.columns: 39 | return Minimum.columns...Maximum.columns 40 | case \.mines: 41 | return Minimum.mines...Maximum.mines(rows: rows, columns: columns) 42 | default: 43 | return 0...0 44 | } 45 | } 46 | } 47 | 48 | public extension Grid { 49 | init( 50 | attributes: MinefieldAttributes, 51 | content: [Content] = [] 52 | ) { 53 | self.init( 54 | rows: Int(attributes.rows), 55 | columns: Int(attributes.columns), 56 | content: content 57 | ) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /TCAminesweeper/Services/HighScoreService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HighScoreService.swift 3 | // TCAminesweeper 4 | // 5 | // Created by Igor Bidiniuc on 09/03/2021. 6 | // 7 | 8 | import ComposableArchitecture 9 | import TCAminesweeperCommon 10 | 11 | public struct HighScoreService { 12 | public var isScoreInTop10: (Difficulty, Int) -> Effect 13 | public var scores: (Difficulty) -> Effect<[UserHighScore], Never> 14 | public var saveScore: (UserHighScore, Difficulty) -> Effect 15 | 16 | public init( 17 | isScoreInTop10: @escaping (Difficulty, Int) -> Effect, 18 | scores: @escaping (Difficulty) -> Effect<[UserHighScore], Never>, 19 | saveScore: @escaping (UserHighScore, Difficulty) -> Effect 20 | ) { 21 | self.isScoreInTop10 = isScoreInTop10 22 | self.scores = scores 23 | self.saveScore = saveScore 24 | } 25 | } 26 | 27 | #if DEBUG 28 | 29 | public extension HighScoreService { 30 | static func mock( 31 | isScoreInTop10: @escaping (Difficulty, Int) -> Effect = { _,_ in fatalError() }, 32 | scores: @escaping (Difficulty) -> Effect<[UserHighScore], Never> = { _ in fatalError() }, 33 | saveScore: @escaping (UserHighScore, Difficulty) -> Effect = { _,_ in fatalError() } 34 | ) -> Self { 35 | Self( 36 | isScoreInTop10: isScoreInTop10, 37 | scores: scores, 38 | saveScore: saveScore 39 | ) 40 | } 41 | 42 | static let preview = Self.mock( 43 | isScoreInTop10: {_,_ in .none }, 44 | scores: {_ in .none}, 45 | saveScore: {_,_ in .none} 46 | ) 47 | } 48 | 49 | #endif 50 | -------------------------------------------------------------------------------- /TCAminesweeper/Services/Live/LiveHighScoreService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LiveHighScoreService.swift 3 | // TCAminesweeper 4 | // 5 | // Created by Igor Bidiniuc on 10/03/2021. 6 | // 7 | import Foundation 8 | import ComposableArchitecture 9 | import HighScoreService 10 | import TCAminesweeperCommon 11 | 12 | extension HighScoreService { 13 | static var liveDatabase: HighScoreDatabaseProtocol = UserDefaults.standard 14 | 15 | static let live = Self( 16 | isScoreInTop10: { difficulty, score in 17 | guard difficulty != .custom else { return .none } 18 | 19 | return Effect.result { 20 | let scores = liveDatabase.highScores(for: difficulty) 21 | return .success(scores.count < 10 ? true : score < (scores.last?.score ?? Int.max)) 22 | } 23 | }, 24 | scores: { difficulty in 25 | Effect.result { 26 | return .success(liveDatabase.highScores(for: difficulty)) 27 | } 28 | }, 29 | saveScore: { userScore, difficulty in 30 | .fireAndForget { 31 | guard difficulty != .custom else { assertionFailure(); return } 32 | 33 | var scores = liveDatabase.highScores(for: difficulty) 34 | scores.append(userScore) 35 | let top10 = Array(scores.sorted(by: { $0.score < $1.score }).prefix(10)) 36 | liveDatabase.saveHighScores(top10, for: difficulty) 37 | } 38 | } 39 | ) 40 | } 41 | 42 | protocol HighScoreDatabaseProtocol { 43 | func highScores(for difficulty: Difficulty) -> [UserHighScore] 44 | func saveHighScores(_ scores: [UserHighScore], for difficulty: Difficulty) 45 | } 46 | 47 | extension UserDefaults: HighScoreDatabaseProtocol { 48 | private static func highScoresKey(for difficulty: Difficulty) -> String { 49 | "\(difficulty.rawValue)-HighScores" 50 | } 51 | 52 | func highScores(for difficulty: Difficulty) -> [UserHighScore] { 53 | guard let data = object(forKey: Self.highScoresKey(for: difficulty)) as? Data else { return [] } 54 | do { 55 | return try JSONDecoder().decode([UserHighScore].self, from: data) 56 | } catch { 57 | #if DEBUG 58 | NSLog("Failed to decode UserSettings. Error: \(error.localizedDescription)") 59 | #endif 60 | clean(for: difficulty) 61 | return [] 62 | } 63 | } 64 | 65 | func saveHighScores(_ scores: [UserHighScore], for difficulty: Difficulty) { 66 | let data = try? JSONEncoder().encode(scores) 67 | set(data, forKey: Self.highScoresKey(for: difficulty)) 68 | } 69 | 70 | private func clean(for difficulty: Difficulty) { 71 | removeObject(forKey: Self.highScoresKey(for: difficulty)) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /TCAminesweeper/Services/Live/LiveSettingsService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LiveSettingsService.swift 3 | // TCAminesweeper 4 | // 5 | // Created by Igor Bidiniuc on 10/03/2021. 6 | // 7 | import Foundation 8 | import ComposableArchitecture 9 | import TCAminesweeperCommon 10 | import SettingsService 11 | 12 | extension SettingsService { 13 | static var liveDatabase: SettingsDatabaseProtocol = UserDefaults.standard 14 | 15 | static let live = Self( 16 | userSettings: { 17 | Effect.result { .success(liveDatabase.userSettings() ?? .default) } 18 | 19 | }, 20 | saveUserSettings: { userSettings in 21 | .fireAndForget { liveDatabase.saveUserSettings(userSettings) } 22 | } 23 | ) 24 | } 25 | 26 | protocol SettingsDatabaseProtocol { 27 | func userSettings() -> UserSettings? 28 | func saveUserSettings(_ settings: UserSettings) 29 | } 30 | 31 | extension UserDefaults: SettingsDatabaseProtocol { 32 | private static let userSetttingsKey = "UserSettings" 33 | 34 | func userSettings() -> UserSettings? { 35 | guard let data = object(forKey: Self.userSetttingsKey) as? Data else { return nil } 36 | do { 37 | return try JSONDecoder().decode(UserSettings.self, from: data) 38 | } catch { 39 | #if DEBUG 40 | print("Failed to decode UserSettings. Error: \(error.localizedDescription)") 41 | #endif 42 | clean() 43 | return nil 44 | } 45 | 46 | } 47 | 48 | func saveUserSettings(_ settings: UserSettings) { 49 | let data = try? JSONEncoder().encode(settings) 50 | set(data, forKey: Self.userSetttingsKey) 51 | } 52 | 53 | private func clean() { 54 | removeObject(forKey: Self.userSetttingsKey) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /TCAminesweeper/Services/SettingsService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsService.swift 3 | // TCAminesweeper 4 | // 5 | // Created by Igor Bidiniuc on 10/03/2021. 6 | // 7 | 8 | import ComposableArchitecture 9 | import TCAminesweeperCommon 10 | 11 | public struct SettingsService { 12 | public let userSettings: () -> Effect 13 | public let saveUserSettings: (UserSettings) -> Effect 14 | 15 | public init( 16 | userSettings: @escaping () -> Effect, 17 | saveUserSettings: @escaping (UserSettings) -> Effect 18 | ) { 19 | self.userSettings = userSettings 20 | self.saveUserSettings = saveUserSettings 21 | } 22 | } 23 | 24 | #if DEBUG 25 | 26 | public extension SettingsService { 27 | static func mock( 28 | userSettings: @escaping () -> Effect = { fatalError() }, 29 | saveUserSettings: @escaping (UserSettings) -> Effect = {_ in fatalError()} 30 | ) -> Self { 31 | Self( 32 | userSettings: userSettings, 33 | saveUserSettings: saveUserSettings 34 | ) 35 | } 36 | 37 | static let preview = Self.mock( 38 | userSettings: { .none }, 39 | saveUserSettings: {_ in .none} 40 | ) 41 | } 42 | 43 | #endif 44 | -------------------------------------------------------------------------------- /TCAminesweeper/SwiftUIViews/TextAlert.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextAlert.swift 3 | // TCAminesweeper 4 | // 5 | // Created by Igor Bidiniuc on 09/03/2021. 6 | // 7 | 8 | import SwiftUI 9 | import UIKit 10 | 11 | extension UIAlertController { 12 | convenience init(alert: TextAlert) { 13 | self.init(title: alert.title, message: nil, preferredStyle: .alert) 14 | addTextField { 15 | $0.placeholder = alert.placeholder 16 | $0.autocapitalizationType = .words 17 | } 18 | addAction(UIAlertAction(title: alert.cancel, style: .cancel) { _ in 19 | alert.action(nil) 20 | }) 21 | let textField = self.textFields?.first 22 | addAction(UIAlertAction(title: alert.accept, style: .default) { _ in 23 | alert.action(textField?.text) 24 | }) 25 | } 26 | } 27 | 28 | 29 | 30 | struct AlertWrapper: UIViewControllerRepresentable { 31 | @Binding var isPresented: Bool 32 | let alert: TextAlert 33 | let content: Content 34 | 35 | func makeUIViewController(context: UIViewControllerRepresentableContext) -> UIHostingController { 36 | UIHostingController(rootView: content) 37 | } 38 | 39 | final class Coordinator { 40 | var alertController: UIAlertController? 41 | init(_ controller: UIAlertController? = nil) { 42 | self.alertController = controller 43 | } 44 | } 45 | 46 | func makeCoordinator() -> Coordinator { 47 | return Coordinator() 48 | } 49 | 50 | 51 | func updateUIViewController(_ uiViewController: UIHostingController, context: UIViewControllerRepresentableContext) { 52 | uiViewController.rootView = content 53 | if isPresented && uiViewController.presentedViewController == nil { 54 | var alert = self.alert 55 | alert.action = { 56 | self.isPresented = false 57 | self.alert.action($0) 58 | } 59 | context.coordinator.alertController = UIAlertController(alert: alert) 60 | uiViewController.present(context.coordinator.alertController!, animated: true) 61 | } 62 | if !isPresented && uiViewController.presentedViewController == context.coordinator.alertController { 63 | uiViewController.dismiss(animated: true) 64 | } 65 | } 66 | } 67 | 68 | public struct TextAlert { 69 | public var title: String 70 | public var placeholder: String = "" 71 | public var accept: String 72 | public var cancel: String 73 | public var action: (String?) -> () 74 | 75 | public init( 76 | title: String, 77 | placeholder: String, 78 | accept: String = "OK", 79 | cancel: String = "Cancel", 80 | action: @escaping (String?) -> () 81 | ) { 82 | self.title = title 83 | self.placeholder = placeholder 84 | self.accept = accept 85 | self.cancel = cancel 86 | self.action = action 87 | } 88 | } 89 | 90 | extension View { 91 | public func alert(isPresented: Binding, _ alert: TextAlert) -> some View { 92 | AlertWrapper(isPresented: isPresented, alert: alert, content: self) 93 | .background(Color.background) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /TCAminesweeper/SwiftUIViews/TileViews.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TileViews.swift 3 | // TCAminesweeper 4 | // 5 | // Created by Igor Bidiniuc on 04/03/2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct OverlayTile: View { 11 | var isMarked = false 12 | var onTap: (() -> Void)? = nil 13 | var onLongPress: (() -> Void)? = nil 14 | @State private var isLongPressCalled = false 15 | 16 | private var longPressGesture: some Gesture { 17 | LongPressGesture(minimumDuration: 0.3) 18 | .onEnded { _ in 19 | self.isLongPressCalled = true 20 | self.onLongPress?() 21 | } 22 | } 23 | 24 | var body: some View { 25 | Button(action: { 26 | if self.isLongPressCalled { self.isLongPressCalled = false } 27 | else { self.onTap?() } 28 | }) { 29 | ZStack { 30 | Rectangle() 31 | .fill(Color.overlay) 32 | .cornerRadius(5) 33 | .padding(2) 34 | 35 | if isMarked { 36 | Text("🚩") 37 | .font(.caption) 38 | } 39 | } 40 | } 41 | .simultaneousGesture(self.longPressGesture) 42 | } 43 | } 44 | 45 | struct TextTile: View { 46 | let text: String 47 | let textColor: Color 48 | 49 | var body: some View { 50 | Text(text) 51 | .foregroundColor(textColor) 52 | .font(.caption) 53 | .fontWeight(.semibold) 54 | } 55 | } 56 | 57 | struct BombTile: View { 58 | let text: String 59 | let isMarked: Bool 60 | 61 | var body: some View { 62 | ZStack { 63 | TextTile(text: text, textColor: .black) 64 | 65 | if isMarked { 66 | Image(systemName: "xmark") 67 | .font(.title2) 68 | .foregroundColor(.red) 69 | } 70 | } 71 | } 72 | } 73 | 74 | #if DEBUG 75 | 76 | struct TileViews_Previews: PreviewProvider { 77 | static var previews: some View { 78 | Group { 79 | OverlayTile(onTap: {}, onLongPress: {}) 80 | OverlayTile(isMarked: true) 81 | TextTile(text: "1", textColor: .blue) 82 | BombTile(text: "💥", isMarked: false) 83 | } 84 | .previewLayout(.fixed(width: 30, height: 30)) 85 | } 86 | } 87 | 88 | #endif 89 | -------------------------------------------------------------------------------- /TCAminesweeper/TCAminesweeperApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TCAminesweeperApp.swift 3 | // TCAminesweeper 4 | // 5 | // Created by Igor Bidiniuc on 03/03/2021. 6 | // 7 | 8 | import SwiftUI 9 | import ComposableArchitecture 10 | import MainAppScene 11 | import MainAppCore 12 | 13 | @main 14 | struct TCAminesweeperApp: App { 15 | #if targetEnvironment(macCatalyst) 16 | @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate 17 | #endif 18 | 19 | var body: some Scene { 20 | MainAppScene(store: Store( 21 | initialState: MainAppState(), 22 | reducer: mainAppReducer, 23 | environment: .live 24 | )) 25 | } 26 | } 27 | 28 | #if targetEnvironment(macCatalyst) 29 | final class AppDelegate: UIResponder, UIApplicationDelegate { 30 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 31 | return true 32 | } 33 | 34 | override func buildMenu(with builder: UIMenuBuilder) { 35 | super.buildMenu(with: builder) 36 | 37 | builder.remove(menu: .file) 38 | builder.remove(menu: .edit) 39 | builder.remove(menu: .format) 40 | builder.remove(menu: .view) 41 | builder.remove(menu: .services) 42 | } 43 | } 44 | #endif 45 | -------------------------------------------------------------------------------- /TCAminesweeper/Views/AppView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppView.swift 3 | // TCAminesweeper 4 | // 5 | // Created by Igor Bidiniuc on 13/03/2021. 6 | // 7 | 8 | import SwiftUI 9 | import ComposableArchitecture 10 | import AppCore 11 | import HighScoresView 12 | import NewGameView 13 | import SettingsView 14 | 15 | public struct AppView: View { 16 | public let store: Store 17 | 18 | public init(store: Store) { 19 | self.store = store 20 | } 21 | 22 | public var body: some View { 23 | WithViewStore(self.store) { viewStore in 24 | NewGameView( 25 | store: self.store.scope( 26 | state: \.newGame, 27 | action: AppAction.newGameAction 28 | )) 29 | .sheet( 30 | item: viewStore.binding( 31 | get: \.sheet, 32 | send: AppAction.dismiss 33 | ) 34 | ) { sheet in 35 | switch sheet { 36 | case .settings: 37 | IfLetStore(self.store.scope(state: \.settings, action: AppAction.settingsAction)) { store in 38 | NavigationView { 39 | SettingsView(store: store) 40 | } 41 | .navigationViewStyle(StackNavigationViewStyle()) 42 | } 43 | case .highScores: 44 | IfLetStore(self.store.scope(state: \.highScores, action: AppAction.highScoresAction)) { store in 45 | NavigationView { 46 | HighScoreView(store: store) 47 | } 48 | .navigationViewStyle(StackNavigationViewStyle()) 49 | } 50 | } 51 | } 52 | .toolbar { 53 | ToolbarItemGroup(placement: .bottomBar) { 54 | Button(action: { viewStore.send(.settingsButtonTapped) }) { 55 | Image(systemName: "gear") 56 | } 57 | 58 | Spacer() 59 | 60 | Button(action: { viewStore.send(.highScoresButtonTapped) }) { 61 | Image(systemName: "list.number") 62 | } 63 | } 64 | } 65 | } 66 | } 67 | } 68 | 69 | #if DEBUG 70 | 71 | struct AppView_Previews: PreviewProvider { 72 | static var previews: some View { 73 | AppView( 74 | store: Store( 75 | initialState: AppState(), 76 | reducer: appReducer, 77 | environment: .preview 78 | ) 79 | ) 80 | } 81 | } 82 | 83 | #endif 84 | -------------------------------------------------------------------------------- /TCAminesweeper/Views/GameView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GameView.swift 3 | // TCAminesweeper 4 | // 5 | // Created by Igor Bidiniuc on 06/03/2021. 6 | // 7 | 8 | import ComposableArchitecture 9 | import GameCore 10 | import MinefieldView 11 | import SwiftUI 12 | import TCAminesweeperCommon 13 | 14 | public struct GameView: View { 15 | struct ViewState: Equatable { 16 | var navigationBarLeadingText: String 17 | var navigationBarTrailingText: String 18 | var navigationBarCenterText: String 19 | } 20 | 21 | enum ViewAction { 22 | case headerButtonTapped 23 | case onDisappear 24 | case onAppear 25 | } 26 | 27 | public let store: Store 28 | @Environment(\.scenePhase) private var scenePhase 29 | 30 | public init(store: Store) { 31 | self.store = store 32 | } 33 | 34 | public var body: some View { 35 | WithViewStore(self.store.scope(state: { $0.view }, action: GameAction.view)) { viewStore in 36 | VStack { 37 | MinefieldView(store: self.store.scope( 38 | state: \.minefieldState, 39 | action: GameAction.minefieldAction 40 | )) 41 | } 42 | .onChange(of: scenePhase) { newScenePhase in 43 | if newScenePhase == .active { 44 | viewStore.send(.onAppear) 45 | } else { 46 | viewStore.send(.onDisappear) 47 | } 48 | } 49 | .toolbar { self.toolbarContent(viewStore: viewStore) } 50 | .navigationBarTitleDisplayMode(.inline) 51 | .background(Color.background.ignoresSafeArea()) 52 | } 53 | } 54 | 55 | @ToolbarContentBuilder 56 | func toolbarContent(viewStore: ViewStore) -> some ToolbarContent { 57 | ToolbarItem(placement: .principal) { 58 | Button(action: { viewStore.send(.headerButtonTapped) }) { 59 | Text(viewStore.navigationBarCenterText) 60 | .font(.header) 61 | } 62 | } 63 | ToolbarItem(placement: .navigationBarLeading) { 64 | Text(viewStore.navigationBarLeadingText) 65 | .font(.header) 66 | } 67 | ToolbarItem(placement: .navigationBarTrailing) { 68 | Text(viewStore.navigationBarTrailingText) 69 | .font(.header) 70 | } 71 | } 72 | } 73 | 74 | extension GameState { 75 | var view: GameView.ViewState { 76 | GameView.ViewState( 77 | navigationBarLeadingText: headerState.leadingText, 78 | navigationBarTrailingText: headerState.trailingText, 79 | navigationBarCenterText: headerState.centerText 80 | ) 81 | } 82 | } 83 | 84 | extension GameAction { 85 | static func view(_ localAction: GameView.ViewAction) -> Self { 86 | switch localAction { 87 | case .headerButtonTapped: 88 | return .headerAction(.buttonTapped) 89 | case .onAppear: 90 | return .onAppear 91 | case .onDisappear: 92 | return .onDisappear 93 | } 94 | } 95 | } 96 | 97 | #if DEBUG 98 | 99 | struct GameView_Previews: PreviewProvider { 100 | static var previews: some View { 101 | GameView( 102 | store: Store( 103 | initialState: GameState( 104 | difficulty: .easy, 105 | minefieldState: .randomState( 106 | rows: 10, 107 | columns: 10)), 108 | reducer: gameReducer, 109 | environment: .preview 110 | ) 111 | ) 112 | } 113 | } 114 | 115 | #endif 116 | -------------------------------------------------------------------------------- /TCAminesweeper/Views/HighScoreView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HighScoreView.swift 3 | // TCAminesweeper 4 | // 5 | // Created by Igor Bidiniuc on 10/03/2021. 6 | // 7 | 8 | import SwiftUI 9 | import ComposableArchitecture 10 | import HighScoresCore 11 | import TCAminesweeperCommon 12 | 13 | public struct HighScoreView: View { 14 | 15 | struct RowViewState: Equatable { 16 | var userName: String 17 | var score: String 18 | } 19 | 20 | public let store: Store 21 | 22 | public init(store: Store) { 23 | self.store = store 24 | } 25 | 26 | public var body: some View { 27 | WithViewStore(self.store) { viewStore in 28 | List { 29 | Section( 30 | header: Picker( 31 | selection: viewStore.binding(get: \.difficulty, send: HighScoreAction.difficultyChanged), 32 | label: EmptyView() 33 | ) { 34 | ForEach(viewStore.categories, id: \.self) { category in 35 | Text(category.title) 36 | } 37 | } 38 | .pickerStyle(SegmentedPickerStyle()) 39 | .padding(5) 40 | ) { 41 | ForEachStore(self.store.scope(state: \.scores, action: HighScoreAction.scoreAction)) { store in 42 | WithViewStore(store.scope(state: \.value.view)) { viewStore in 43 | rowView(viewStore: viewStore) 44 | } 45 | } 46 | } 47 | } 48 | .listStyle(InsetGroupedListStyle()) 49 | .onAppear { viewStore.send(.loadScores) } 50 | .navigationTitle("High Scores") 51 | .toolbar { 52 | ToolbarItem(placement: .cancellationAction) { 53 | Button("Close") { viewStore.send(.cancelButtonTapped) } 54 | .keyboardShortcut(.cancelAction) 55 | } 56 | } 57 | } 58 | } 59 | 60 | func rowView(viewStore: ViewStore) -> some View { 61 | HStack { 62 | Text(viewStore.userName) 63 | Spacer() 64 | Text(viewStore.score) 65 | } 66 | } 67 | } 68 | 69 | extension UserHighScore { 70 | var view: HighScoreView.RowViewState { 71 | HighScoreView.RowViewState( 72 | userName: userName ?? "No name", 73 | score: String(score) 74 | ) 75 | } 76 | } 77 | 78 | #if DEBUG 79 | 80 | struct HighScoreView_Previews: PreviewProvider { 81 | static var previews: some View { 82 | HighScoreView( 83 | store: Store( 84 | initialState: HighScoreState(difficulty: .normal), 85 | reducer: highScoreReducer, 86 | environment: .preview 87 | ) 88 | ) 89 | } 90 | } 91 | 92 | #endif 93 | -------------------------------------------------------------------------------- /TCAminesweeper/Views/MainAppScene.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainAppScene.swift 3 | // TCAminesweeper 4 | // 5 | // Created by Igor Bidiniuc on 27/03/2021. 6 | // 7 | 8 | import SwiftUI 9 | import ComposableArchitecture 10 | import MainAppCore 11 | import AppView 12 | 13 | public struct MainAppScene: Scene { 14 | public let store: Store 15 | @Environment(\.openURL) private var openURL 16 | 17 | public init(store: Store) { 18 | self.store = store 19 | } 20 | 21 | public var body: some Scene { 22 | WithViewStore(store) { viewStore in 23 | WindowGroup { 24 | AppView(store: self.store.scope(state: \.app, action: MainAppAction.appAction)) 25 | } 26 | .commands { 27 | CommandMenu("Game") { 28 | Button("New Game") { viewStore.send(.newGameCommand) } 29 | .keyboardShortcut("n") 30 | 31 | Divider() 32 | 33 | Button("Settings") { viewStore.send(.settingsCommand) } 34 | .keyboardShortcut(",") 35 | } 36 | 37 | CommandGroup(replacing: CommandGroupPlacement.help) { 38 | Button("Github Page") { 39 | openURL(URL(string: "https://github.com/RogyMD/TCAminesweeper")!) 40 | } 41 | } 42 | } 43 | } 44 | } 45 | } 46 | 47 | -------------------------------------------------------------------------------- /TCAminesweeper/Views/MinefieldView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MinefieldView.swift 3 | // TCAminesweeper 4 | // 5 | // Created by Igor Bidiniuc on 06/03/2021. 6 | // 7 | 8 | import ComposableArchitecture 9 | import SwiftUI 10 | import TileCore 11 | import TileView 12 | import MinefieldCore 13 | 14 | public struct MinefieldView: View { 15 | public let store: Store 16 | 17 | public init(store: Store) { 18 | self.store = store 19 | } 20 | 21 | public var body: some View { 22 | WithViewStore(self.store) { viewStore in 23 | ScrollView([.horizontal, .vertical]) { 24 | LazyVGrid(columns: self.gridItems(for: viewStore.grid.columns), spacing: 0) { 25 | ForEachStore(self.store.scope(state: \.grid.content, action: MinefieldAction.tile)) { store in 26 | TileView(store: store) 27 | } 28 | } 29 | .disabled(viewStore.isDisabled) 30 | .padding() 31 | } 32 | } 33 | } 34 | 35 | private func gridItems(for columns: Int) -> [GridItem] { 36 | Array( 37 | repeating: GridItem(.fixed(30), spacing: 0), 38 | count: columns 39 | ) 40 | } 41 | } 42 | 43 | extension MinefieldState { 44 | var isDisabled: Bool { result != nil } 45 | } 46 | 47 | #if DEBUG 48 | import TCAminesweeperCommon 49 | 50 | struct MinefieldView_Previews: PreviewProvider { 51 | static var previews: some View { 52 | MinefieldView( 53 | store: Store( 54 | initialState: MinefieldState( 55 | grid: Grid( 56 | rows: 2, 57 | columns: 2, 58 | content: Grid.twoMines.content.map { TileState(id: $0.id, tile: $0.tile, isHidden: false, isMarked: false) } 59 | ) 60 | ), 61 | reducer: minefieldReducer, 62 | environment: .preview)) 63 | } 64 | } 65 | 66 | #endif 67 | -------------------------------------------------------------------------------- /TCAminesweeper/Views/NewGameView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NewGameView.swift 3 | // TCAminesweeper 4 | // 5 | // Created by Igor Bidiniuc on 08/03/2021. 6 | // 7 | 8 | import SwiftUI 9 | import ComposableArchitecture 10 | import NewGameCore 11 | import GameView 12 | import TCAminesweeperCommon 13 | 14 | public struct NewGameView: View { 15 | struct ViewState { 16 | var showsHighScoreAlert: Bool 17 | } 18 | 19 | public let store: Store 20 | 21 | public init(store: Store) { 22 | self.store = store 23 | } 24 | 25 | public var body: some View { 26 | WithViewStore(self.store) { viewStore in 27 | IfLetStore(self.store.scope(state: \.game, action: NewGameAction.gameAction)) { store in 28 | NavigationView { 29 | GameView(store: store) 30 | } 31 | .navigationViewStyle(StackNavigationViewStyle()) 32 | } 33 | .alert( 34 | isPresented: viewStore.binding(get: \.showsHighScoreAlert, send: { $0 ? .showAlert : .dismissAlert }), 35 | TextAlert( 36 | title: "You're in TOP 10!", 37 | placeholder: "Name", 38 | action: { viewStore.send(.alertActionButtonTapped($0)) } 39 | ) 40 | ) 41 | .onAppear { viewStore.send(.startNewGame) } 42 | .ignoresSafeArea() 43 | } 44 | } 45 | } 46 | 47 | #if DEBUG 48 | 49 | struct NewGameView_Previews: PreviewProvider { 50 | static var previews: some View { 51 | NewGameView( 52 | store: Store( 53 | initialState: NewGameState(), 54 | reducer: newGameReducer, 55 | environment: .preview 56 | ) 57 | ) 58 | .preferredColorScheme(.dark) 59 | } 60 | } 61 | 62 | #endif 63 | -------------------------------------------------------------------------------- /TCAminesweeper/Views/SettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsView.swift 3 | // TCAminesweeper 4 | // 5 | // Created by Igor Bidiniuc on 08/03/2021. 6 | // 7 | 8 | import SwiftUI 9 | import ComposableArchitecture 10 | import SettingsCore 11 | import TCAminesweeperCommon 12 | 13 | public struct SettingsView: View { 14 | public let store: Store 15 | 16 | public init(store: Store) { 17 | self.store = store 18 | } 19 | 20 | public var body: some View { 21 | WithViewStore(self.store) { viewStore in 22 | Form { 23 | Section(header: Text("Difficulty")) { 24 | Picker( 25 | selection: viewStore.binding(keyPath: \.difficulty, send: SettingsAction.binding), 26 | label: Text("Difficulty") 27 | ) { 28 | ForEach(viewStore.difficulties, id: \.self) { difficulty in 29 | Text(difficulty.title) 30 | } 31 | } 32 | .pickerStyle(SegmentedPickerStyle()) 33 | } 34 | 35 | Section(header: Text("Customize")) { 36 | stepperRow(title: "Rows", keyPath: \.rows, viewStore: viewStore) 37 | stepperRow(title: "Columns", keyPath: \.columns, viewStore: viewStore) 38 | stepperRow(title: "Mines", keyPath: \.mines, viewStore: viewStore) 39 | } 40 | .disabled(viewStore.isCustomizeSectionDisabled) 41 | 42 | Section { 43 | Button(action: { viewStore.send(.newGameButtonTapped) }) { 44 | Text("New game") 45 | } 46 | } 47 | } 48 | .navigationTitle("Settings") 49 | .toolbar { 50 | ToolbarItem(placement: .cancellationAction) { 51 | Button("Close") { viewStore.send(.cancelButtonTapped) } 52 | .keyboardShortcut(.cancelAction) 53 | } 54 | } 55 | } 56 | } 57 | 58 | func stepperRow( 59 | title: String, 60 | keyPath: WritableKeyPath, 61 | viewStore: ViewStore 62 | ) -> some View { 63 | HStack { 64 | Stepper( 65 | title, 66 | value: viewStore.binding(keyPath: appending(to: \.minefieldAttributes, path: keyPath), send: SettingsAction.binding), 67 | in: viewStore.minefieldAttributes.range(forKeyPath: keyPath) 68 | ) 69 | Text("\(viewStore[dynamicMember: appending(to: \.minefieldAttributes, path: keyPath)])") 70 | .bold() 71 | } 72 | } 73 | } 74 | 75 | extension SettingsState { 76 | var isCustomizeSectionDisabled: Bool { difficulty != .custom } 77 | } 78 | 79 | func appending(to root: WritableKeyPath, path: WritableKeyPath) -> WritableKeyPath { 80 | return root.appending(path: path) 81 | } 82 | 83 | #if DEBUG 84 | 85 | struct SettingsView_Previews: PreviewProvider { 86 | static var previews: some View { 87 | SettingsView( 88 | store: Store( 89 | initialState: SettingsState(userSettings: .default), 90 | reducer: settingsReducer, 91 | environment: .preview 92 | ) 93 | ) 94 | } 95 | } 96 | 97 | #endif 98 | -------------------------------------------------------------------------------- /TCAminesweeper/Views/TileView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TileView.swift 3 | // TCAminesweeper 4 | // 5 | // Created by Igor Bidiniuc on 06/03/2021. 6 | // 7 | 8 | import ComposableArchitecture 9 | import TileCore 10 | import SwiftUI 11 | import TCAminesweeperCommon 12 | 13 | public struct TileView: View { 14 | struct ViewState: Equatable { 15 | var isMine: Bool 16 | var isEmpty: Bool 17 | var isHidden: Bool 18 | var isMarked: Bool 19 | var text: String 20 | var textColor: Color 21 | } 22 | 23 | public let store: Store 24 | 25 | public init(store: Store) { 26 | self.store = store 27 | } 28 | 29 | public var body: some View { 30 | WithViewStore(self.store.scope(state: { $0.view })) { viewStore in 31 | if viewStore.isHidden { 32 | OverlayTile( 33 | isMarked: viewStore.isMarked, 34 | onTap: { viewStore.send(.tapped) }, 35 | onLongPress: { viewStore.send(.longPressed) } 36 | ) 37 | 38 | } else if viewStore.isMine { 39 | BombTile( 40 | text: viewStore.text, 41 | isMarked: viewStore.isMarked 42 | ) 43 | } else { 44 | TextTile( 45 | text: viewStore.text, 46 | textColor: viewStore.textColor 47 | ) 48 | } 49 | } 50 | .frame(width: 30, height: 30) 51 | } 52 | } 53 | 54 | extension TileState { 55 | var view: TileView.ViewState { 56 | TileView.ViewState( 57 | isMine: tile.isMine, 58 | isEmpty: tile.isEmpty, 59 | isHidden: isHidden, 60 | isMarked: isMarked, 61 | text: tile.description, 62 | textColor: tile.textColor 63 | ) 64 | } 65 | } 66 | 67 | extension Tile { 68 | var textColor: Color { 69 | switch self { 70 | case .explosion, 71 | .mine, 72 | .empty: 73 | return .black 74 | case .one: 75 | return .blue 76 | case .two: 77 | return .green 78 | case .three: 79 | return .red 80 | case .four: 81 | return .darkBlue4 82 | case .five: 83 | return .darkRed5 84 | case .six: 85 | return .lightBlue6 86 | case .seven: 87 | return .black 88 | case .eight: 89 | return .secondary 90 | } 91 | } 92 | } 93 | 94 | #if DEBUG 95 | 96 | struct TileView_Previews: PreviewProvider { 97 | static var previews: some View { 98 | TileView( 99 | store: Store( 100 | initialState: TileState( 101 | tile: .mine, 102 | isHidden: false, 103 | isMarked: false 104 | ), 105 | reducer: tileReducer, 106 | environment: TileEnvironment() 107 | ) 108 | ) 109 | } 110 | } 111 | 112 | #endif 113 | -------------------------------------------------------------------------------- /Tests/AppCoreTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppCoreTests.swift 3 | // AppCoreTests 4 | // 5 | // Created by Igor Bidiniuc on 17/03/2021. 6 | // 7 | 8 | import XCTest 9 | import ComposableArchitecture 10 | import SettingsService 11 | import SettingsCore 12 | import NewGameCore 13 | import GameCore 14 | import TCAminesweeperCommon 15 | import HighScoreService 16 | import HighScoresCore 17 | @testable import AppCore 18 | 19 | class AppCoreTests: XCTestCase { 20 | 21 | func testFlow_Settings() { 22 | let userSettingsMock = UserSettings(otherThanCustom: .easy) 23 | let settingsMock = SettingsState(userSettings: userSettingsMock) 24 | let settingsServiceMock = SettingsService.mock(userSettings: { Effect(value: userSettingsMock) }) 25 | let store = TestStore( 26 | initialState: AppState(newGame: NewGameState(game: GameState(difficulty: .easy, minefieldState: .oneMine))), 27 | reducer: appReducer, 28 | environment: .mock( 29 | newGame: .mock( 30 | minefieldGenerator: { _ in .none }, 31 | settingsService: settingsServiceMock 32 | ), 33 | settings: .mock(settingsService: settingsServiceMock) 34 | ) 35 | ) 36 | 37 | let openSettings: TestStore.Step = 38 | .sequence([ 39 | .send(.settingsButtonTapped), 40 | .receive(.showSettings(settingsMock)) { 41 | $0.settings = settingsMock 42 | $0.sheet = .settings 43 | }, 44 | .receive(.newGameAction(.gameAction(.onDisappear))) 45 | ]) 46 | 47 | store.assert( 48 | openSettings, 49 | .send(.settingsAction(.cancelButtonTapped)) { 50 | $0.sheet = nil 51 | }, 52 | 53 | openSettings, 54 | .send(.dismiss) { 55 | $0.settings = nil 56 | $0.sheet = nil 57 | }, 58 | .receive(.newGameAction(.gameAction(.onAppear))) 59 | ) 60 | } 61 | 62 | func testFlow_HighScore() { 63 | let scoresMock = [UserHighScore(id: .init(), score: 0, userName: nil, date: Date())] 64 | let highScoresMock = HighScoreState(difficulty: .easy, scores: scoresMock) 65 | let store = TestStore( 66 | initialState: AppState(newGame: NewGameState(game: GameState(difficulty: .easy, minefieldState: .oneMine))), 67 | reducer: appReducer, 68 | environment: .mock( 69 | highScores: .mock(highScoreService: HighScoreService.mock(scores: { difficulty in 70 | XCTAssertEqual(difficulty, .easy) 71 | return Effect(value: scoresMock) 72 | })) 73 | ) 74 | ) 75 | 76 | let openHighScores: TestStore.Step = 77 | .sequence([ 78 | .send(.highScoresButtonTapped), 79 | .receive(.showHighScores(highScoresMock)) { 80 | $0.highScores = highScoresMock 81 | $0.sheet = .highScores 82 | }, 83 | .receive(.newGameAction(.gameAction(.onDisappear))) 84 | ]) 85 | 86 | store.assert( 87 | openHighScores, 88 | .send(.highScoresAction(.cancelButtonTapped)) { 89 | $0.sheet = nil 90 | }, 91 | 92 | openHighScores, 93 | .send(.dismiss) { 94 | $0.highScores = nil 95 | $0.sheet = nil 96 | }, 97 | .receive(.newGameAction(.gameAction(.onAppear))) 98 | ) 99 | } 100 | 101 | } 102 | -------------------------------------------------------------------------------- /Tests/GameCoreTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GameCoreTests.swift 3 | // GameCoreTests 4 | // 5 | // Created by Igor Bidiniuc on 16/03/2021. 6 | // 7 | 8 | import XCTest 9 | import TCAminesweeperCommon 10 | import ComposableArchitecture 11 | import MinefieldCore 12 | @testable import GameCore 13 | 14 | class GameCoreTests: XCTestCase { 15 | 16 | let timerScheduler = DispatchQueue.testScheduler 17 | let mainQueue = DispatchQueue.testScheduler 18 | var selectionFeedbackCalled = false 19 | var notificationFeedbackType: GameEnvironment.NotificationFeedbackType? 20 | 21 | var resultMock: MinefieldState.Result? 22 | 23 | func testFlow_Win() { 24 | store.assert( 25 | .sequence(startGame()), 26 | .sequence(scoreIncrement()), 27 | .sequence(gameOver(result: .win, gameState: .over(score: 1))) 28 | ) 29 | } 30 | 31 | func testFlow_Lost() { 32 | store.assert( 33 | .sequence(startGame()), 34 | .sequence(scoreIncrement()), 35 | .sequence(gameOver(result: .lost, gameState: .over(score: nil))) 36 | ) 37 | } 38 | 39 | func testFlow_RestartGame() { 40 | store.assert( 41 | .sequence(startGame()), 42 | .sequence(gameOver(result: .lost, gameState: .over(score: nil))), 43 | .send(.startNewGame(.twoMines)) { 44 | $0.minefieldState = .twoMines 45 | }, 46 | .receive(.gameStateChanged(.new)) { 47 | $0.gameState = .new 48 | $0.headerState = HeaderState( 49 | leadingText: "002", 50 | centerText: "😴", 51 | trailingText: "000" 52 | ) 53 | } 54 | ) 55 | } 56 | 57 | func testFlow_MarkTile() { 58 | store.assert( 59 | .send(.minefieldAction(.tile(0, .longPressed))), 60 | .receive(.minefieldAction(.toogleMark(0))) { 61 | $0.minefieldState.grid.content[0].isMarked = true 62 | $0.minefieldState.gridInfo.flagged = [0] 63 | XCTAssertTrue(self.selectionFeedbackCalled) 64 | }, 65 | .receive(.gameStateChanged(.inProgress(0))) { 66 | $0.gameState = .inProgress(0) 67 | }, 68 | .receive(.updateRemainedMines) { 69 | $0.headerState.leadingText = "000" 70 | }, 71 | .receive(.updateRemainedMines), 72 | .receive(.gameStarted) { 73 | $0.headerState.centerText = "🙂" 74 | }, 75 | .sequence(scoreIncrement()), 76 | .sequence(gameOver(result: .win, gameState: .over(score: 1))) 77 | ) 78 | } 79 | 80 | func test_onDisapper() { 81 | store.assert( 82 | .sequence(startGame()), 83 | .send(.onDisappear), 84 | .do { 85 | self.timerScheduler.advance(by: 2) 86 | self.mainQueue.advance() 87 | }, 88 | .send(.onAppear), 89 | .receive(.gameStarted), 90 | .do { 91 | self.timerScheduler.advance(by: 1) 92 | self.mainQueue.advance() 93 | }, 94 | .receive(.timerUpdated) { 95 | $0.gameState = .inProgress(1) 96 | $0.headerState.trailingText = "001" 97 | }, 98 | .sequence(gameOver(result: .win, gameState: .over(score: 1))) 99 | ) 100 | } 101 | 102 | // MARK: - Steps 103 | 104 | private func scoreIncrement() -> [TestStore.Step] { 105 | [ 106 | .do { 107 | self.timerScheduler.advance(by: 1) 108 | self.mainQueue.advance() 109 | }, 110 | .receive(.timerUpdated) { 111 | $0.gameState = .inProgress(1) 112 | $0.headerState.trailingText = "001" 113 | }, 114 | ] 115 | } 116 | 117 | private func startGame(leadingText: String = "001") -> [TestStore.Step] { 118 | [ 119 | .send(.minefieldAction(.tile(0, .tapped))), 120 | .receive(.gameStateChanged(.inProgress(0))) { 121 | $0.gameState = .inProgress(0) 122 | }, 123 | .receive(.updateRemainedMines) { 124 | $0.headerState.leadingText = leadingText 125 | }, 126 | .receive(.gameStarted) { 127 | $0.headerState.centerText = "🙂" 128 | } 129 | ] 130 | } 131 | 132 | private func gameOver( 133 | result: MinefieldState.Result, 134 | gameState: GameState.State 135 | ) -> [TestStore.Step] { 136 | [ 137 | .do { 138 | self.resultMock = result 139 | }, 140 | .send(.minefieldAction(.tile(0, .tapped))), 141 | .receive(.minefieldAction(.resultChanged(result))) { 142 | $0.minefieldState.result = result 143 | }, 144 | .receive(.gameStateChanged(gameState)) { 145 | $0.gameState = gameState 146 | $0.headerState.centerText = result.isWin ? "😎" : "🤯" 147 | XCTAssertEqual(self.notificationFeedbackType, result.isWin ? .success : .error) 148 | }, 149 | .do { 150 | self.timerScheduler.advance() 151 | self.mainQueue.advance() 152 | } 153 | ] 154 | } 155 | 156 | lazy var store = TestStore( 157 | initialState: GameState( 158 | difficulty: .easy, 159 | minefieldState: .oneMine 160 | ), 161 | reducer: gameReducer, 162 | environment: .mock( 163 | minefieldEnvironment: .mock(tileTappedHandler: {_, _ in 164 | guard let result = self.resultMock else { return .none } 165 | return Effect(value: result) 166 | }), 167 | timerScheduler: .init(timerScheduler), 168 | mainQueue: .init(mainQueue), 169 | selectionFeedback: { [weak self] in 170 | self?.selectionFeedbackCalled = true 171 | return .none 172 | }, 173 | notificationFeedback: { [weak self] notificationType in 174 | self?.notificationFeedbackType = notificationType 175 | return.none 176 | } 177 | ) 178 | ) 179 | } 180 | -------------------------------------------------------------------------------- /Tests/GameViewTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GameViewTests.swift 3 | // GameViewTests 4 | // 5 | // Created by Igor Bidiniuc on 16/03/2021. 6 | // 7 | 8 | import XCTest 9 | import ComposableArchitecture 10 | import GameCore 11 | import MinefieldCore 12 | @testable import GameView 13 | 14 | class GameViewTests: XCTestCase { 15 | 16 | let timerScheduler = DispatchQueue.testScheduler 17 | let mainQueue = DispatchQueue.testScheduler 18 | 19 | var resultMock: MinefieldState.Result? 20 | var selectionFeedbackCalled = false 21 | var notificationFeedbackType: GameEnvironment.NotificationFeedbackType? 22 | 23 | func testFlow_MarkTile_Win() { 24 | store.assert( 25 | .send(.minefieldAction(.tile(0, .longPressed))), 26 | .receive(.minefieldAction(.toogleMark(0))) { _ in 27 | XCTAssertTrue(self.selectionFeedbackCalled) 28 | }, 29 | .receive(.gameStateChanged(.inProgress(0))), 30 | .receive(.updateRemainedMines) { 31 | $0.navigationBarLeadingText = "000" 32 | }, 33 | .receive(.updateRemainedMines), 34 | .receive(.gameStarted) { 35 | $0.navigationBarCenterText = "🙂" 36 | }, 37 | .sequence(gameOver(result: .win, gameState: .over(score: 0))) 38 | ) 39 | } 40 | 41 | func testFlow_UpdateScore_Lost() { 42 | store.assert( 43 | .send(.minefieldAction(.tile(0, .tapped))), 44 | .receive(.gameStateChanged(.inProgress(0))), 45 | .receive(.updateRemainedMines) { 46 | $0.navigationBarTrailingText = "000" 47 | }, 48 | .receive(.gameStarted) { 49 | $0.navigationBarCenterText = "🙂" 50 | }, 51 | .do { 52 | self.timerScheduler.advance(by: 2) 53 | self.mainQueue.advance(by: 2) 54 | }, 55 | .receive(.timerUpdated) { 56 | $0.navigationBarTrailingText = "001" 57 | }, 58 | .receive(.timerUpdated) { 59 | $0.navigationBarTrailingText = "002" 60 | }, 61 | .sequence(gameOver(result: .lost, gameState: .over(score: nil))) 62 | ) 63 | } 64 | 65 | private func gameOver( 66 | result: MinefieldState.Result, 67 | gameState: GameState.State 68 | ) -> [TestStore.Step] { 69 | [ 70 | .do { 71 | self.resultMock = result 72 | }, 73 | .send(.minefieldAction(.tile(0, .tapped))), 74 | .receive(.minefieldAction(.resultChanged(result))), 75 | .receive(.gameStateChanged(gameState)) { 76 | $0.navigationBarCenterText = result.isWin ? "😎" : "🤯" 77 | }, 78 | .do { 79 | self.timerScheduler.advance() 80 | self.mainQueue.advance() 81 | } 82 | ] 83 | } 84 | 85 | lazy var store = TestStore( 86 | initialState: GameState( 87 | difficulty: .easy, 88 | minefieldState: .oneMine 89 | ), 90 | reducer: gameReducer, 91 | environment: .mock( 92 | minefieldEnvironment: .mock(tileTappedHandler: {_, _ in 93 | guard let result = self.resultMock else { return .none } 94 | return Effect(value: result) 95 | }), 96 | timerScheduler: .init(timerScheduler), 97 | mainQueue: .init(mainQueue), 98 | selectionFeedback: { self.selectionFeedbackCalled = true; return .none }, 99 | notificationFeedback: { self.notificationFeedbackType = $0; return .none } 100 | )) 101 | .scope(state: { $0.view }) 102 | } 103 | -------------------------------------------------------------------------------- /Tests/Live/GridTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GridTests.swift 3 | // TCAminesweeperTests 4 | // 5 | // Created by Igor Bidiniuc on 18/03/2021. 6 | // 7 | 8 | import XCTest 9 | import TileCore 10 | import TCAminesweeperCommon 11 | import SnapshotTesting 12 | @testable import TCAminesweeper 13 | 14 | class GridTests: XCTestCase { 15 | 16 | override func setUpWithError() throws { 17 | try super.setUpWithError() 18 | 19 | // isRecording = true 20 | } 21 | 22 | func test_indexesBeside_12() { 23 | let grid = Grid(rows: 5, columns: 5) 24 | let index = 12 25 | let besideIndexes = grid.indexes(beside: index).sorted() 26 | 27 | assertSnapshot(matching: besideIndexes, as: .description) 28 | } 29 | 30 | func test_indexesBeside_24() { 31 | let grid = Grid(rows: 10, columns: 10) 32 | let index = 24 33 | let besideIndexes = grid.indexes(beside: index).sorted() 34 | 35 | assertSnapshot(matching: besideIndexes, as: .description) 36 | } 37 | 38 | func test_indexesBeside_0() { 39 | let grid = Grid(rows: 10, columns: 10) 40 | let index = 0 41 | let besideIndexes = grid.indexes(beside: index).sorted() 42 | 43 | assertSnapshot(matching: besideIndexes, as: .description) 44 | } 45 | 46 | func test_indexesBeside_10() { 47 | let grid = Grid(rows: 10, columns: 10) 48 | let index = 9 49 | let besideIndexes = grid.indexes(beside: index).sorted() 50 | 51 | assertSnapshot(matching: besideIndexes, as: .description) 52 | } 53 | 54 | func test_fromString_withDescription() { 55 | let grid = Grid.randomGrid() 56 | let string = grid.description 57 | let gridFromString = Grid.fromDescription(string) 58 | 59 | XCTAssertEqual(grid, gridFromString) 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /Tests/Live/LiveHighScoreServiceTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LiveHighScoreServiceTests.swift 3 | // TCAminesweeperTests 4 | // 5 | // Created by Igor Bidiniuc on 18/03/2021. 6 | // 7 | 8 | import XCTest 9 | import Combine 10 | import ComposableArchitecture 11 | import HighScoreService 12 | import TCAminesweeperCommon 13 | @testable import LiveHighScoreService 14 | 15 | class LiveHighScoreServiceTests: XCTestCase { 16 | var cancellables: Set = [] 17 | 18 | private var databaseMock: HighScoreDatabaseMock! 19 | var sut: HighScoreService! 20 | 21 | override func setUpWithError() throws { 22 | try super.setUpWithError() 23 | 24 | databaseMock = HighScoreDatabaseMock() 25 | HighScoreService.liveDatabase = databaseMock 26 | 27 | sut = .live 28 | } 29 | 30 | override func tearDownWithError() throws { 31 | databaseMock = nil 32 | sut = nil 33 | cancellables = [] 34 | 35 | try super.tearDownWithError() 36 | } 37 | 38 | func testIsScoreInTop10_Custom_ReturnsNone() { 39 | var isComplete = false 40 | var values: [Bool] = [] 41 | sut.isScoreInTop10(.custom, 10) 42 | .sink(receiveCompletion: { _ in isComplete = true }, receiveValue: { values.append($0) }) 43 | .store(in: &cancellables) 44 | 45 | XCTAssertTrue(isComplete) 46 | XCTAssertTrue(values.isEmpty) 47 | } 48 | 49 | func test_isScoreInTop10_Easy_FiveScores_ReturnsTru() { 50 | databaseMock.highScores = Array(repeating: UserHighScore.mock(score: 10), count: 5) 51 | var isComplete = false 52 | var values: [Bool] = [] 53 | 54 | sut.isScoreInTop10(.easy, 15) 55 | .sink(receiveCompletion: { _ in isComplete = true }, receiveValue: { values.append($0) }) 56 | .store(in: &cancellables) 57 | 58 | XCTAssertTrue(isComplete) 59 | XCTAssertEqual(values, [true]) 60 | } 61 | 62 | func test_isScoreInTop10_Easy_TensScores_ReturnsFalse() { 63 | databaseMock.highScores = Array(repeating: UserHighScore.mock(score: 10), count: 10) 64 | var isComplete = false 65 | var values: [Bool] = [] 66 | 67 | sut.isScoreInTop10(.easy, 11) 68 | .sink(receiveCompletion: { _ in isComplete = true }, receiveValue: { values.append($0) }) 69 | .store(in: &cancellables) 70 | 71 | XCTAssertTrue(isComplete) 72 | XCTAssertEqual(values, [false]) 73 | } 74 | 75 | func test_isScoreInTop10_Easy_TensScores_ReturnsTrue() { 76 | databaseMock.highScores = Array(repeating: UserHighScore.mock(score: 10), count: 10) 77 | var isComplete = false 78 | var values: [Bool] = [] 79 | 80 | sut.isScoreInTop10(.easy, 9) 81 | .sink(receiveCompletion: { _ in isComplete = true }, receiveValue: { values.append($0) }) 82 | .store(in: &cancellables) 83 | 84 | XCTAssertTrue(isComplete) 85 | XCTAssertEqual(values, [true]) 86 | } 87 | 88 | func test_scores() { 89 | databaseMock.highScores = Array(repeating: UserHighScore.mock(score: 10), count: 5) 90 | var isComplete = false 91 | var values: [[UserHighScore]] = [] 92 | 93 | sut.scores(.easy) 94 | .sink(receiveCompletion: { _ in isComplete = true }, receiveValue: { values.append($0) }) 95 | .store(in: &cancellables) 96 | 97 | XCTAssertTrue(isComplete) 98 | XCTAssertEqual(values, [databaseMock.highScores]) 99 | } 100 | 101 | func test_saveHighScore_SixScores_SavesAHighScore() { 102 | let highScore = UserHighScore.mock(score: 10) 103 | databaseMock.highScores = Array(1...6).map { 104 | UserHighScore.mock(score: $0) 105 | } 106 | var isComplete = false 107 | 108 | sut.saveScore(highScore, .easy) 109 | .sink(receiveCompletion: { _ in isComplete = true }, receiveValue: absurd) 110 | .store(in: &cancellables) 111 | 112 | XCTAssertTrue(isComplete) 113 | XCTAssertEqual( 114 | databaseMock.savedHighScores, 115 | (databaseMock.highScores + [highScore]) 116 | ) 117 | } 118 | 119 | func test_saveHighScore_SixScores_SavesALowScore() { 120 | let highScore = UserHighScore.mock(score: 10) 121 | databaseMock.highScores = Array(11...18).map { 122 | UserHighScore.mock(score: $0) 123 | } 124 | var isComplete = false 125 | 126 | sut.saveScore(highScore, .easy) 127 | .sink(receiveCompletion: { _ in isComplete = true }, receiveValue: absurd) 128 | .store(in: &cancellables) 129 | 130 | XCTAssertTrue(isComplete) 131 | XCTAssertEqual( 132 | databaseMock.savedHighScores, 133 | ([highScore] + databaseMock.highScores) 134 | ) 135 | } 136 | 137 | func test_saveHighScore_SixScores_NotSaveAHighScore() { 138 | let highScore = UserHighScore.mock(score: 10) 139 | databaseMock.highScores = Array(0...9).map { 140 | UserHighScore.mock(score: $0) 141 | } 142 | var isComplete = false 143 | 144 | sut.saveScore(highScore, .easy) 145 | .sink(receiveCompletion: { _ in isComplete = true }, receiveValue: absurd) 146 | .store(in: &cancellables) 147 | 148 | XCTAssertTrue(isComplete) 149 | XCTAssertEqual( 150 | databaseMock.savedHighScores, 151 | databaseMock.highScores 152 | ) 153 | } 154 | 155 | } 156 | 157 | private final class HighScoreDatabaseMock: HighScoreDatabaseProtocol { 158 | 159 | var highScores: [UserHighScore] = [] 160 | var savedHighScores: [UserHighScore]? 161 | 162 | func highScores(for difficulty: Difficulty) -> [UserHighScore] { 163 | return self.highScores 164 | } 165 | 166 | func saveHighScores(_ scores: [UserHighScore], for difficulty: Difficulty) { 167 | self.savedHighScores = scores 168 | } 169 | 170 | } 171 | 172 | private extension UUID { 173 | // A deterministic, auto-incrementing "UUID" generator for testing. 174 | static var incrementing: () -> UUID { 175 | var uuid = 0 176 | return { 177 | defer { uuid += 1 } 178 | return UUID(uuidString: "00000000-0000-0000-0000-\(String(format: "%012x", uuid))")! 179 | } 180 | } 181 | } 182 | 183 | private extension UserHighScore { 184 | static func mock( 185 | id: UUID = UUID.incrementing(), 186 | score: Int, 187 | userName: String? = nil, 188 | date: Date = Date() 189 | ) -> Self { 190 | Self( 191 | id: id, 192 | score: score, 193 | userName: userName, 194 | date: date 195 | ) 196 | } 197 | } 198 | 199 | private let absurd: (Never) -> Void = { _ in } 200 | -------------------------------------------------------------------------------- /Tests/Live/LiveMinefieldEnvironmentTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LiveMinefieldEnvironmentTests.swift 3 | // TCAminesweeperTests 4 | // 5 | // Created by Igor Bidiniuc on 20/03/2021. 6 | // 7 | 8 | import XCTest 9 | import MinefieldCore 10 | import TCAminesweeperCommon 11 | import SnapshotTesting 12 | import NewGameCore 13 | @testable import TCAminesweeper 14 | 15 | class LiveMinefieldEnvironmentTests: XCTestCase { 16 | 17 | var state: MinefieldState! 18 | var snapshotState: MinefieldState { state } 19 | 20 | override func setUpWithError() throws { 21 | try super.setUpWithError() 22 | 23 | // isRecording = true 24 | 25 | let grid = try XCTUnwrap(Grid.fromDescription( 26 | """ 27 | ___________ 28 | |11 | 29 | |💣1 | 30 | |11111 111| 31 | | 12💣1112💣1| 32 | | 1💣211💣211| 33 | |12233321 | 34 | |💣22💣💣💣1 | 35 | |2💣22321 | 36 | |111 | 37 | | | 38 | ___________ 39 | """ 40 | )) 41 | let mines = Set(grid.content.indices.filter { grid.content[$0].tile == .mine }) 42 | state = MinefieldState(grid: grid, gridInfo: .init(mines: mines)) 43 | } 44 | 45 | override func tearDownWithError() throws { 46 | state = nil 47 | 48 | try super.tearDownWithError() 49 | } 50 | 51 | func test_tileTappedHandler_tileMine() throws { 52 | let result = MinefieldEnvironment.tileTappedHandler(index: 10, state: &state) 53 | 54 | XCTAssertEqual(result, .lost) 55 | assertSnapshot(matching: snapshotState, as: .description) 56 | } 57 | 58 | func test_tileTappedHandler_tileEmpty() throws { 59 | let result = MinefieldEnvironment.tileTappedHandler(index: 5, state: &state) 60 | 61 | XCTAssertNil(result) 62 | assertSnapshot(matching: snapshotState, as: .description) 63 | } 64 | 65 | func test_tileTappedHandler_tileNumber() throws { 66 | let result = MinefieldEnvironment.tileTappedHandler(index: 0, state: &state) 67 | 68 | XCTAssertNil(result) 69 | assertSnapshot(matching: snapshotState, as: .description) 70 | } 71 | 72 | func test_tileTappedHandler_tileNumber_Win() throws { 73 | state.grid.content.indices.forEach { index in 74 | guard !state.gridInfo.mines.contains(index) else { 75 | return 76 | } 77 | state.reveal(index) 78 | } 79 | let result = MinefieldEnvironment.tileTappedHandler(index: 0, state: &state) 80 | 81 | XCTAssertEqual(result, .win) 82 | assertSnapshot(matching: snapshotState, as: .description) 83 | } 84 | 85 | func test_prepareStateForLoss() throws { 86 | MinefieldEnvironment.prepareStateForLoss(state: &state, mineIndex: 10) 87 | 88 | assertSnapshot(matching: snapshotState, as: .description) 89 | } 90 | 91 | func test_prepareStateForWin() throws { 92 | state.setMarked(true, for: 10) 93 | 94 | MinefieldEnvironment.prepareStateForWin(state: &state) 95 | 96 | assertSnapshot(matching: snapshotState, as: .description) 97 | } 98 | 99 | func test_revealTilesBesideTile() throws { 100 | MinefieldEnvironment.revealTilesBesideTile(at: 4, state: &state) 101 | 102 | assertSnapshot(matching: snapshotState, as: .description) 103 | } 104 | 105 | } 106 | 107 | 108 | -------------------------------------------------------------------------------- /Tests/Live/LiveNewGameEnvironmentTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LiveNewGameEnvironmentTests.swift 3 | // TCAminesweeperTests 4 | // 5 | // Created by Igor Bidiniuc on 18/03/2021. 6 | // 7 | 8 | import XCTest 9 | import ComposableArchitecture 10 | import NewGameCore 11 | import SnapshotTesting 12 | import TCAminesweeperCommon 13 | @testable import TCAminesweeper 14 | 15 | class LiveNewGameEnvironmentTests: XCTestCase { 16 | 17 | override func setUpWithError() throws { 18 | try super.setUpWithError() 19 | 20 | // isRecording = true 21 | } 22 | 23 | func test_minefieldGenerator() { 24 | var state = NewGameEnvironment.minefieldGenerator( 25 | .init(rows: 5, columns: 5, mines: 10), 26 | shuffler: Shuffler { _ in Array(4..<9) + Array(7..<12) } 27 | ) 28 | 29 | assertSnapshot(matching: state, as: .description) 30 | 31 | state = NewGameEnvironment.minefieldGenerator( 32 | .init(rows: 12, columns: 9, mines: 6), 33 | shuffler: Shuffler { _ in [10, 23, 41, 0, 30, 11] } 34 | ) 35 | 36 | assertSnapshot(matching: state, as: .description) 37 | 38 | state = NewGameEnvironment.minefieldGenerator( 39 | .init(rows: 12, columns: 9, mines: 10), 40 | shuffler: Shuffler { _ in [10, 23, 41, 0, 30, 11, 12, 9, 98, 2] } 41 | ) 42 | 43 | assertSnapshot(matching: state, as: .description) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Tests/Live/LiveSettingsServiceTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LiveSettingsServiceTests.swift 3 | // TCAminesweeperTests 4 | // 5 | // Created by Igor Bidiniuc on 18/03/2021. 6 | // 7 | 8 | import XCTest 9 | import Combine 10 | import ComposableArchitecture 11 | import SettingsService 12 | import TCAminesweeperCommon 13 | @testable import LiveSettingsService 14 | 15 | class LiveSettingsServiceTests: XCTestCase { 16 | 17 | var cancellables: Set = [] 18 | 19 | private var databaseMock: SettingsDatabaseMock! 20 | var sut: SettingsService! 21 | 22 | override func setUpWithError() throws { 23 | try super.setUpWithError() 24 | 25 | databaseMock = SettingsDatabaseMock() 26 | SettingsService.liveDatabase = databaseMock 27 | 28 | sut = .live 29 | } 30 | 31 | override func tearDownWithError() throws { 32 | databaseMock = nil 33 | sut = nil 34 | cancellables = [] 35 | 36 | try super.tearDownWithError() 37 | } 38 | 39 | func test_userSettings_Nil_ReturnsDefault() { 40 | var isComplete = false 41 | var values: [UserSettings] = [] 42 | 43 | sut.userSettings() 44 | .sink(receiveCompletion: { _ in isComplete = true }, receiveValue: { values.append($0) }) 45 | .store(in: &cancellables) 46 | 47 | XCTAssertTrue(isComplete) 48 | XCTAssertEqual(values, [.default]) 49 | } 50 | 51 | func test_userSettings_SomeSettings_ReturnsSomeSettings() { 52 | let otherSettings = UserSettings(otherThanCustom: .hard) 53 | databaseMock.userSettingsReturnValue = otherSettings 54 | var isComplete = false 55 | var values: [UserSettings] = [] 56 | 57 | sut.userSettings() 58 | .sink(receiveCompletion: { _ in isComplete = true }, receiveValue: { values.append($0) }) 59 | .store(in: &cancellables) 60 | 61 | XCTAssertTrue(isComplete) 62 | XCTAssertEqual(values, [otherSettings]) 63 | } 64 | 65 | func test_saveUserSettings() { 66 | var isComplete = false 67 | 68 | sut.saveUserSettings(.default) 69 | .sink(receiveCompletion: { _ in isComplete = true }, receiveValue: absurd) 70 | .store(in: &cancellables) 71 | 72 | XCTAssertTrue(isComplete) 73 | XCTAssertEqual(databaseMock.savedUserSettings, .default) 74 | } 75 | } 76 | 77 | private final class SettingsDatabaseMock: SettingsDatabaseProtocol { 78 | var userSettingsReturnValue: UserSettings? 79 | var savedUserSettings: UserSettings? 80 | 81 | func userSettings() -> UserSettings? { 82 | userSettingsReturnValue 83 | } 84 | 85 | func saveUserSettings(_ settings: UserSettings) { 86 | self.savedUserSettings = settings 87 | } 88 | } 89 | 90 | private let absurd: (Never) -> Void = { _ in } 91 | -------------------------------------------------------------------------------- /Tests/Live/MinefieldAttributesTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MinefieldAttributesTests.swift 3 | // MinefieldCoreTests 4 | // 5 | // Created by Igor Bidiniuc on 20/03/2021. 6 | // 7 | 8 | import XCTest 9 | import TCAminesweeperCommon 10 | import SnapshotTesting 11 | @testable import SettingsCore 12 | 13 | class MinefieldAttributesTests: XCTestCase { 14 | 15 | func test_normalize_big() { 16 | var attributes = MinefieldAttributes(rows: 100, columns: 100, mines: 100*100) 17 | attributes.normalize() 18 | assertSnapshot(matching: attributes, as: .description) 19 | } 20 | 21 | func test_normalize_small() { 22 | var attributes = MinefieldAttributes(rows: 1, columns: 1, mines: 0) 23 | attributes.normalize() 24 | assertSnapshot(matching: attributes, as: .description) 25 | } 26 | 27 | func test_normalize_normal() { 28 | var attributes = MinefieldAttributes(rows: 10, columns: 10, mines: 10) 29 | attributes.normalize() 30 | assertSnapshot(matching: attributes, as: .description) 31 | } 32 | 33 | func test_range_rows() { 34 | let attributes = MinefieldAttributes(rows: 10, columns: 10, mines: 10) 35 | let range = attributes.range(forKeyPath: \.rows) 36 | assertSnapshot(matching: range, as: .description) 37 | } 38 | 39 | func test_range_columns() { 40 | let attributes = MinefieldAttributes(rows: 10, columns: 10, mines: 10) 41 | let range = attributes.range(forKeyPath: \.columns) 42 | assertSnapshot(matching: range, as: .description) 43 | } 44 | 45 | func test_range_mines() { 46 | let attributes = MinefieldAttributes(rows: 10, columns: 10, mines: 10) 47 | let range = attributes.range(forKeyPath: \.mines) 48 | assertSnapshot(matching: range, as: .description) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Tests/Live/__Snapshots__/GridTests/test_indexesBeside_0.1.txt: -------------------------------------------------------------------------------- 1 | [1, 10, 11] -------------------------------------------------------------------------------- /Tests/Live/__Snapshots__/GridTests/test_indexesBeside_10.1.txt: -------------------------------------------------------------------------------- 1 | [8, 18, 19] -------------------------------------------------------------------------------- /Tests/Live/__Snapshots__/GridTests/test_indexesBeside_12.1.txt: -------------------------------------------------------------------------------- 1 | [6, 7, 8, 11, 13, 16, 17, 18] -------------------------------------------------------------------------------- /Tests/Live/__Snapshots__/GridTests/test_indexesBeside_24.1.txt: -------------------------------------------------------------------------------- 1 | [13, 14, 15, 23, 25, 33, 34, 35] -------------------------------------------------------------------------------- /Tests/Live/__Snapshots__/LiveMinefieldEnvironmentTests/test_prepareStateForLoss.1.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Tests/Live/__Snapshots__/LiveMinefieldEnvironmentTests/test_prepareStateForWin.1.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Tests/Live/__Snapshots__/LiveMinefieldEnvironmentTests/test_revealTilesBesideTile.1.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Tests/Live/__Snapshots__/LiveMinefieldEnvironmentTests/test_tileTappedHandler_tileEmpty.1.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Tests/Live/__Snapshots__/LiveMinefieldEnvironmentTests/test_tileTappedHandler_tileMine.1.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Tests/Live/__Snapshots__/LiveMinefieldEnvironmentTests/test_tileTappedHandler_tileNumber.1.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Tests/Live/__Snapshots__/LiveNewGameEnvironmentTests/test_minefieldGenerator.1.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Tests/Live/__Snapshots__/MinefieldAttributesTests/test_normalize_big.1.txt: -------------------------------------------------------------------------------- 1 | MinefieldAttributes(rows: 30, columns: 30, mines: 899) -------------------------------------------------------------------------------- /Tests/Live/__Snapshots__/MinefieldAttributesTests/test_normalize_normal.1.txt: -------------------------------------------------------------------------------- 1 | MinefieldAttributes(rows: 10, columns: 10, mines: 10) -------------------------------------------------------------------------------- /Tests/Live/__Snapshots__/MinefieldAttributesTests/test_normalize_small.1.txt: -------------------------------------------------------------------------------- 1 | MinefieldAttributes(rows: 3, columns: 3, mines: 1) -------------------------------------------------------------------------------- /Tests/Live/__Snapshots__/MinefieldAttributesTests/test_range_columns.1.txt: -------------------------------------------------------------------------------- 1 | 3...30 -------------------------------------------------------------------------------- /Tests/Live/__Snapshots__/MinefieldAttributesTests/test_range_mines.1.txt: -------------------------------------------------------------------------------- 1 | 1...99 -------------------------------------------------------------------------------- /Tests/Live/__Snapshots__/MinefieldAttributesTests/test_range_rows.1.txt: -------------------------------------------------------------------------------- 1 | 3...30 -------------------------------------------------------------------------------- /Tests/MainAppCoreTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainAppCoreTests.swift 3 | // MainAppCoreTests 4 | // 5 | // Created by Igor Bidiniuc on 27/03/2021. 6 | // 7 | 8 | import XCTest 9 | import ComposableArchitecture 10 | import AppCore 11 | import SettingsCore 12 | import NewGameCore 13 | import GameCore 14 | @testable import MainAppCore 15 | 16 | class MainAppCoreTests: XCTestCase { 17 | 18 | func test_newGameCommand_settingsNotNil() { 19 | let store = TestStore( 20 | initialState: MainAppState( 21 | app: AppState( 22 | newGame: NewGameState(game: GameState(difficulty: .easy, minefieldState: .oneMine)), 23 | sheet: .settings, 24 | settings: SettingsState(userSettings: .default)) 25 | ), 26 | reducer: mainAppReducer, 27 | environment: .mock( 28 | app: .mock( 29 | newGame: .mock(settingsService: .mock(userSettings: { .none })), 30 | settings: .mock(), 31 | highScores: .mock()) 32 | ) 33 | ) 34 | 35 | store.assert( 36 | .send(.newGameCommand), 37 | .receive(.appAction(.settingsAction(.newGameButtonTapped))), 38 | .receive(.appAction(.newGameAction(.startNewGame))), 39 | .receive(.appAction(.dismiss)) { 40 | $0.app.sheet = nil 41 | $0.app.settings = nil 42 | }, 43 | .receive(.appAction(.newGameAction(.gameAction(.onAppear)))) 44 | ) 45 | } 46 | 47 | func test_newGameCommand_settingsNil() { 48 | let store = TestStore( 49 | initialState: MainAppState( 50 | app: AppState(newGame: NewGameState(game: GameState(difficulty: .easy, minefieldState: .oneMine))) 51 | ), 52 | reducer: mainAppReducer, 53 | environment: .mock( 54 | app: .mock( 55 | newGame: .mock(settingsService: .mock(userSettings: { .none })), 56 | settings: .mock(), 57 | highScores: .mock()) 58 | ) 59 | ) 60 | 61 | store.assert( 62 | .send(.newGameCommand), 63 | .receive(.appAction(.newGameAction(.startNewGame))) 64 | ) 65 | } 66 | 67 | func test_settingsCommand() { 68 | let store = TestStore( 69 | initialState: MainAppState( 70 | app: AppState(newGame: NewGameState(game: GameState(difficulty: .easy, minefieldState: .oneMine))) 71 | ), 72 | reducer: mainAppReducer, 73 | environment: .mock( 74 | app: .mock( 75 | newGame: .mock(), 76 | settings: .mock(settingsService: .mock(userSettings: { .none })), 77 | highScores: .mock()) 78 | ) 79 | ) 80 | 81 | store.assert( 82 | .send(.settingsCommand), 83 | .receive(.appAction(.settingsButtonTapped)) 84 | ) 85 | } 86 | 87 | 88 | } 89 | -------------------------------------------------------------------------------- /Tests/MinefieldCoreTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MinefieldCoreTests.swift 3 | // TCAminesweeper 4 | // 5 | // Created by Igor Bidiniuc on 15/03/2021. 6 | // 7 | 8 | import XCTest 9 | import TCAminesweeperCommon 10 | import ComposableArchitecture 11 | import TileCore 12 | @testable import MinefieldCore 13 | 14 | class MinefieldCoreTests: XCTestCase { 15 | var resultMock: MinefieldState.Result? 16 | 17 | func testTileTapped() { 18 | self.resultMock = .lost 19 | 20 | store.assert( 21 | .send(.tile(1, .tapped)), 22 | .receive(.resultChanged(.lost)) { 23 | $0.result = .lost 24 | } 25 | ) 26 | } 27 | 28 | func testTileLongPressed() { 29 | store.assert( 30 | .send(.tile(1, .longPressed)), 31 | .receive(.toogleMark(1)) { 32 | $0.grid.content[1].isMarked = true 33 | $0.gridInfo.flagged = [1] 34 | }, 35 | .send(.tile(1, .longPressed)), 36 | .receive(.toogleMark(1)) { 37 | $0.grid.content[1].isMarked = false 38 | $0.gridInfo.flagged = [] 39 | } 40 | ) 41 | } 42 | 43 | lazy var store = TestStore( 44 | initialState: .oneMine, 45 | reducer: minefieldReducer, 46 | environment: .mock(tileTappedHandler: {_,_ in 47 | if let result = self.resultMock { 48 | return Effect(value: result) 49 | } else { 50 | return .none 51 | } 52 | }) 53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /Tests/MinefieldStateTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MinefieldStateTests.swift 3 | // MinefieldCoreTests 4 | // 5 | // Created by Igor Bidiniuc on 20/03/2021. 6 | // 7 | 8 | import XCTest 9 | import SnapshotTesting 10 | import TCAminesweeperCommon 11 | @testable import MinefieldCore 12 | 13 | class MinefieldStateTests: XCTestCase { 14 | 15 | override func setUpWithError() throws { 16 | try super.setUpWithError() 17 | 18 | // isRecording = true 19 | } 20 | 21 | func test_reveal() { 22 | var state = MinefieldState.twoMines 23 | 24 | state.reveal(1) 25 | 26 | assertSnapshot(matching: state, as: .description) 27 | 28 | state.setMarked(true, for: 2) 29 | state.reveal(2) 30 | 31 | assertSnapshot(matching: state, as: .description) 32 | } 33 | 34 | func test_setTile() { 35 | var state = MinefieldState.twoMines 36 | 37 | state.setTile(.eight, for: 0) 38 | 39 | assertSnapshot(matching: state, as: .description) 40 | } 41 | 42 | func test_setMarked() { 43 | var state = MinefieldState.twoMines 44 | 45 | state.setMarked(true, for: 0) 46 | 47 | assertSnapshot(matching: state, as: .description) 48 | 49 | state.setMarked(true, for: 1) 50 | 51 | assertSnapshot(matching: state, as: .description) 52 | 53 | state.setMarked(false, for: 0) 54 | 55 | assertSnapshot(matching: state, as: .description) 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /Tests/NewGameCoreTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NewGameCoreTests.swift 3 | // NewGameCoreTests 4 | // 5 | // Created by Igor Bidiniuc on 15/03/2021. 6 | // 7 | 8 | import XCTest 9 | import ComposableArchitecture 10 | import GameCore 11 | import TCAminesweeperCommon 12 | import MinefieldCore 13 | import NewGameCore 14 | import TileCore 15 | import SettingsService 16 | 17 | class NewGameCoreTests: XCTestCase { 18 | 19 | let timerScheduler = DispatchQueue.testScheduler 20 | let mainQueue = DispatchQueue.testScheduler 21 | 22 | var minefieldGeneratorMock: MinefieldState = .oneMine 23 | var uuid = UUID(uuidString: "00000000-0000-0000-0000-000000000000")! 24 | var now = Date(timeIntervalSince1970: 0) 25 | var resultMock: MinefieldState.Result? 26 | var showsHighScoreAlert = false 27 | var savedScores: [UserHighScore] = [] 28 | 29 | func testFlow_Win_ShowsHighScoreAlertTrue() { 30 | showsHighScoreAlert = true 31 | 32 | store.assert( 33 | .sequence(startNewGame()), 34 | .sequence(gameOver(result: .win, gameState: .over(score: 0))), 35 | .receive(.showAlert) { 36 | $0.showsHighScoreAlert = true 37 | }, 38 | .send(.alertActionButtonTapped("Igor")) { _ in 39 | self.assertSavedScores([UserHighScore(id: self.uuid, score: 0, userName: "Igor", date: self.now)]) 40 | }, 41 | .send(.dismissAlert) { 42 | $0.showsHighScoreAlert = false 43 | } 44 | ) 45 | } 46 | 47 | func testFlow_Win_ShowsHighScoreAlertFalse() { 48 | showsHighScoreAlert = false 49 | 50 | store.assert( 51 | .sequence(startNewGame()), 52 | .sequence(gameOver(result: .win, gameState: .over(score: 0))) 53 | ) 54 | } 55 | 56 | func testFlow_Lost() { 57 | store.assert( 58 | .sequence(startNewGame()), 59 | .sequence(gameOver(result: .lost, gameState: .over(score: nil))) 60 | ) 61 | } 62 | 63 | func testRestartGame() { 64 | let newGame = GameState(difficulty: .easy, minefieldState: .twoMines) 65 | 66 | store.assert( 67 | .sequence(startNewGame()), 68 | .do { 69 | self.minefieldGeneratorMock = newGame.minefieldState 70 | }, 71 | .send(.gameAction(.headerAction(.buttonTapped))), 72 | .receive(.startNewGame), 73 | .receive(.newGame(newGame)) { 74 | $0.game = newGame 75 | }, 76 | .receive(.gameAction(.startNewGame(newGame.minefieldState))), 77 | .receive(.gameAction(.gameStateChanged(.new))), 78 | .send(.gameAction(.minefieldAction(.tile(0, .tapped)))), 79 | .receive(.gameAction(.gameStateChanged(.inProgress(0)))) { 80 | $0.game?.gameState = .inProgress(0) 81 | }, 82 | .receive(.gameAction(.updateRemainedMines)) { 83 | $0.game?.headerState.leadingText = "002" 84 | }, 85 | .receive(.gameAction(.gameStarted)) { 86 | $0.game?.headerState.centerText = "🙂" 87 | }, 88 | .sequence(gameOver(result: .lost, gameState: .over(score: nil))) 89 | ) 90 | } 91 | 92 | private func startNewGame( 93 | difficulty: Difficulty = .easy, 94 | minefield: MinefieldState = .oneMine 95 | ) -> [TestStore.Step] { 96 | let game = GameState(difficulty: difficulty, minefieldState: minefield) 97 | 98 | return [ 99 | .send(.startNewGame), 100 | .receive(.newGame(game)) { 101 | $0.game = game 102 | }, 103 | .receive(.gameAction(.startNewGame(game.minefieldState))), 104 | .receive(.gameAction(.gameStateChanged(.new))), 105 | .send(.gameAction(.minefieldAction(.tile(0, .tapped)))), 106 | .receive(.gameAction(.gameStateChanged(.inProgress(0)))) { 107 | $0.game?.gameState = .inProgress(0) 108 | }, 109 | .receive(.gameAction(.updateRemainedMines)) { 110 | $0.game?.headerState.leadingText = String(format: "%03d", minefield.gridInfo.mines.count) 111 | }, 112 | .receive(.gameAction(.gameStarted)) { 113 | $0.game?.headerState.centerText = "🙂" 114 | } 115 | ] 116 | } 117 | 118 | private func gameOver( 119 | result: MinefieldState.Result, 120 | gameState: GameState.State 121 | ) -> [TestStore.Step] { 122 | return [ 123 | .do { 124 | self.resultMock = result 125 | }, 126 | .send(.gameAction(.minefieldAction(.tile(0, .tapped)))), 127 | .receive(.gameAction(.minefieldAction(.resultChanged(result)))) { 128 | $0.game?.minefieldState.result = result 129 | 130 | // advance timers to finish the game 131 | self.timerScheduler.advance() 132 | self.mainQueue.advance() 133 | }, 134 | .receive(.gameAction(.gameStateChanged(gameState))) { 135 | $0.game?.gameState = gameState 136 | $0.game?.headerState.centerText = result.isWin ? "😎" : "🤯" 137 | } 138 | ] 139 | } 140 | 141 | private func assertSavedScores(_ scores: [UserHighScore]) { 142 | XCTAssertEqual(self.savedScores, scores) 143 | self.savedScores.removeAll() 144 | } 145 | 146 | lazy var store = TestStore( 147 | initialState: NewGameState(), 148 | reducer: newGameReducer, 149 | environment: .mock( 150 | minefieldGenerator: { _ in 151 | Effect(value: self.minefieldGeneratorMock) 152 | }, 153 | uuid: { self.uuid }, 154 | now: { self.now }, 155 | game: .mock( 156 | minefieldEnvironment: .mock(tileTappedHandler: {_, _ in 157 | guard let result = self.resultMock else { return .none } 158 | return Effect(value: result) 159 | }), 160 | timerScheduler: .init(self.timerScheduler), 161 | mainQueue: .init(self.mainQueue), 162 | selectionFeedback: { .none }, 163 | notificationFeedback: {_ in .none } 164 | ), 165 | settingsService: .mock( 166 | userSettings: { Effect(value: UserSettings(otherThanCustom: .easy)) } 167 | ), 168 | highScoreService: .mock( 169 | isScoreInTop10: {_, _ in Effect(value: self.showsHighScoreAlert) }, 170 | saveScore: { userScore, _ in 171 | self.savedScores.append(userScore) 172 | return .none 173 | } 174 | ) 175 | )) 176 | } 177 | -------------------------------------------------------------------------------- /Tests/SettingsCoreTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsCoreTests.swift 3 | // SettingsCoreTests 4 | // 5 | // Created by Igor Bidiniuc on 20/03/2021. 6 | // 7 | 8 | import XCTest 9 | import ComposableArchitecture 10 | import TCAminesweeperCommon 11 | import SettingsService 12 | @testable import SettingsCore 13 | 14 | class SettingsCoreTests: XCTestCase { 15 | 16 | func test_binding_difficulty() { 17 | let store = TestStore( 18 | initialState: SettingsState(userSettings: UserSettings(otherThanCustom: .easy)), 19 | reducer: settingsReducer, 20 | environment: .mock(settingsService: .mock(saveUserSettings: { _ in .none })) 21 | ) 22 | 23 | store.assert( 24 | .send(.binding(.set(\.difficulty, .normal))) { 25 | $0.difficulty = .normal 26 | $0.minefieldAttributes = Difficulty.normal.minefieldAttributes! 27 | }, 28 | .receive(.saveSettings), 29 | .send(.binding(.set(\.difficulty, .custom))) { 30 | $0.difficulty = .custom 31 | }, 32 | .receive(.saveSettings) 33 | ) 34 | } 35 | 36 | func test_saveSettings() { 37 | var savedUserSettings: UserSettings? 38 | let store = TestStore( 39 | initialState: SettingsState(userSettings: UserSettings(otherThanCustom: .easy)), 40 | reducer: settingsReducer, 41 | environment: .mock(settingsService: .mock(saveUserSettings: { 42 | savedUserSettings = $0 43 | return .none 44 | })) 45 | ) 46 | 47 | store.assert( 48 | .send(.saveSettings) { 49 | XCTAssertEqual($0.userSettings, savedUserSettings) 50 | } 51 | ) 52 | } 53 | 54 | func test_binding_minefieldAttributes() { 55 | let userSettings = UserSettings(minefieldAttributes: MinefieldAttributes(rows: 100, columns: 100, mines: 100*100)) 56 | let store = TestStore( 57 | initialState: SettingsState(userSettings: userSettings), 58 | reducer: settingsReducer, 59 | environment: .mock(settingsService: .mock(saveUserSettings: { _ in .none })) 60 | ) 61 | 62 | store.assert( 63 | .send(.binding(.set(\SettingsState.minefieldAttributes.rows, UInt(100)))) { 64 | $0.minefieldAttributes.normalize() 65 | }, 66 | .receive(.saveSettings) 67 | ) 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /Tests/TestPlans/AllTests.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "37512B7B-C3B2-4694-BCF9-24B7B1387322", 5 | "name" : "Configuration 1", 6 | "options" : { 7 | "targetForVariableExpansion" : { 8 | "containerPath" : "container:TCAminesweeper.xcodeproj", 9 | "identifier" : "A5497EC22603DD7000C5AD5B", 10 | "name" : "TCAminesweeperTests" 11 | } 12 | } 13 | } 14 | ], 15 | "defaultOptions" : { 16 | "targetForVariableExpansion" : { 17 | "containerPath" : "container:TCAminesweeper.xcodeproj", 18 | "identifier" : "A5497EC22603DD7000C5AD5B", 19 | "name" : "TCAminesweeperTests" 20 | } 21 | }, 22 | "testTargets" : [ 23 | { 24 | "target" : { 25 | "containerPath" : "container:TCAminesweeper.xcodeproj", 26 | "identifier" : "A5D02CE526028E8C0004D542", 27 | "name" : "AppCoreTests" 28 | } 29 | }, 30 | { 31 | "target" : { 32 | "containerPath" : "container:TCAminesweeper.xcodeproj", 33 | "identifier" : "A5573F7F2600DE220098EBE4", 34 | "name" : "GameCoreTests" 35 | } 36 | }, 37 | { 38 | "target" : { 39 | "containerPath" : "container:TCAminesweeper.xcodeproj", 40 | "identifier" : "A5574037260114670098EBE4", 41 | "name" : "GameViewTests" 42 | } 43 | }, 44 | { 45 | "target" : { 46 | "containerPath" : "container:TCAminesweeper.xcodeproj", 47 | "identifier" : "A549809526053BD900C5AD5B", 48 | "name" : "LiveHighScoreServiceTests" 49 | } 50 | }, 51 | { 52 | "target" : { 53 | "containerPath" : "container:TCAminesweeper.xcodeproj", 54 | "identifier" : "A549813B26053CBD00C5AD5B", 55 | "name" : "LiveSettingsServiceTests" 56 | } 57 | }, 58 | { 59 | "target" : { 60 | "containerPath" : "container:TCAminesweeper.xcodeproj", 61 | "identifier" : "A590E2D125FFC0D300019BF4", 62 | "name" : "MinefieldCoreTests" 63 | } 64 | }, 65 | { 66 | "target" : { 67 | "containerPath" : "container:TCAminesweeper.xcodeproj", 68 | "identifier" : "A5CCBEA925FE5132001E57FA", 69 | "name" : "NewGameCoreTests" 70 | } 71 | }, 72 | { 73 | "target" : { 74 | "containerPath" : "container:TCAminesweeper.xcodeproj", 75 | "identifier" : "A568FD5626062ECC00475045", 76 | "name" : "SettingsCoreTests" 77 | } 78 | }, 79 | { 80 | "target" : { 81 | "containerPath" : "container:TCAminesweeper.xcodeproj", 82 | "identifier" : "A590E1F4260FD08100834E66", 83 | "name" : "MainAppCoreTests" 84 | } 85 | }, 86 | { 87 | "target" : { 88 | "containerPath" : "container:TCAminesweeper.xcodeproj", 89 | "identifier" : "A5497EC22603DD7000C5AD5B", 90 | "name" : "TCAminesweeperTests" 91 | } 92 | } 93 | ], 94 | "version" : 1 95 | } 96 | -------------------------------------------------------------------------------- /Tests/__Snapshots__/MinefieldStateTests/test_reveal.1.txt: -------------------------------------------------------------------------------- 1 | (rows: 2, columns: 2, content: [TileCore.TileState(id: 0, tile: 1, isHidden: true, isMarked: false), TileCore.TileState(id: 1, tile: 💣, isHidden: false, isMarked: false), TileCore.TileState(id: 2, tile: 1, isHidden: true, isMarked: false), TileCore.TileState(id: 3, tile: 💣, isHidden: true, isMarked: false)]) 3 | content: [TileCore.TileState(id: 0, tile: 1, isHidden: true, isMarked: false), TileCore.TileState(id: 1, tile: 💣, isHidden: false, isMarked: false), TileCore.TileState(id: 2, tile: 1, isHidden: true, isMarked: false), TileCore.TileState(id: 3, tile: 💣, isHidden: true, isMarked: false)] 4 | gridInfo: -------------------------------------------------------------------------------- /Tests/__Snapshots__/MinefieldStateTests/test_reveal.2.txt: -------------------------------------------------------------------------------- 1 | (rows: 2, columns: 2, content: [TileCore.TileState(id: 0, tile: 1, isHidden: true, isMarked: false), TileCore.TileState(id: 1, tile: 💣, isHidden: false, isMarked: false), TileCore.TileState(id: 2, tile: 1, isHidden: false, isMarked: true), TileCore.TileState(id: 3, tile: 💣, isHidden: true, isMarked: false)]) 3 | content: [TileCore.TileState(id: 0, tile: 1, isHidden: true, isMarked: false), TileCore.TileState(id: 1, tile: 💣, isHidden: false, isMarked: false), TileCore.TileState(id: 2, tile: 1, isHidden: false, isMarked: true), TileCore.TileState(id: 3, tile: 💣, isHidden: true, isMarked: false)] 4 | gridInfo: -------------------------------------------------------------------------------- /Tests/__Snapshots__/MinefieldStateTests/test_setMarked.1.txt: -------------------------------------------------------------------------------- 1 | (rows: 2, columns: 2, content: [TileCore.TileState(id: 0, tile: 1, isHidden: true, isMarked: true), TileCore.TileState(id: 1, tile: 💣, isHidden: true, isMarked: false), TileCore.TileState(id: 2, tile: 1, isHidden: true, isMarked: false), TileCore.TileState(id: 3, tile: 💣, isHidden: true, isMarked: false)]) 3 | content: [TileCore.TileState(id: 0, tile: 1, isHidden: true, isMarked: true), TileCore.TileState(id: 1, tile: 💣, isHidden: true, isMarked: false), TileCore.TileState(id: 2, tile: 1, isHidden: true, isMarked: false), TileCore.TileState(id: 3, tile: 💣, isHidden: true, isMarked: false)] 4 | gridInfo: -------------------------------------------------------------------------------- /Tests/__Snapshots__/MinefieldStateTests/test_setMarked.2.txt: -------------------------------------------------------------------------------- 1 | (rows: 2, columns: 2, content: [TileCore.TileState(id: 0, tile: 1, isHidden: true, isMarked: true), TileCore.TileState(id: 1, tile: 💣, isHidden: true, isMarked: true), TileCore.TileState(id: 2, tile: 1, isHidden: true, isMarked: false), TileCore.TileState(id: 3, tile: 💣, isHidden: true, isMarked: false)]) 3 | content: [TileCore.TileState(id: 0, tile: 1, isHidden: true, isMarked: true), TileCore.TileState(id: 1, tile: 💣, isHidden: true, isMarked: true), TileCore.TileState(id: 2, tile: 1, isHidden: true, isMarked: false), TileCore.TileState(id: 3, tile: 💣, isHidden: true, isMarked: false)] 4 | gridInfo: -------------------------------------------------------------------------------- /Tests/__Snapshots__/MinefieldStateTests/test_setMarked.3.txt: -------------------------------------------------------------------------------- 1 | (rows: 2, columns: 2, content: [TileCore.TileState(id: 0, tile: 1, isHidden: true, isMarked: false), TileCore.TileState(id: 1, tile: 💣, isHidden: true, isMarked: true), TileCore.TileState(id: 2, tile: 1, isHidden: true, isMarked: false), TileCore.TileState(id: 3, tile: 💣, isHidden: true, isMarked: false)]) 3 | content: [TileCore.TileState(id: 0, tile: 1, isHidden: true, isMarked: false), TileCore.TileState(id: 1, tile: 💣, isHidden: true, isMarked: true), TileCore.TileState(id: 2, tile: 1, isHidden: true, isMarked: false), TileCore.TileState(id: 3, tile: 💣, isHidden: true, isMarked: false)] 4 | gridInfo: -------------------------------------------------------------------------------- /Tests/__Snapshots__/MinefieldStateTests/test_setTile.1.txt: -------------------------------------------------------------------------------- 1 | (rows: 2, columns: 2, content: [TileCore.TileState(id: 0, tile: 8, isHidden: true, isMarked: false), TileCore.TileState(id: 1, tile: 💣, isHidden: true, isMarked: false), TileCore.TileState(id: 2, tile: 1, isHidden: true, isMarked: false), TileCore.TileState(id: 3, tile: 💣, isHidden: true, isMarked: false)]) 3 | content: [TileCore.TileState(id: 0, tile: 8, isHidden: true, isMarked: false), TileCore.TileState(id: 1, tile: 💣, isHidden: true, isMarked: false), TileCore.TileState(id: 2, tile: 1, isHidden: true, isMarked: false), TileCore.TileState(id: 3, tile: 💣, isHidden: true, isMarked: false)] 4 | gridInfo: --------------------------------------------------------------------------------