├── .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 | 
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 | ||
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:
--------------------------------------------------------------------------------